Add .csproj "project mode" to winapp run#612
Draft
nmetulev wants to merge 16 commits into
Draft
Conversation
Implements project mode for winapp run per specs/winapp-run-csproj.md (v0.4): point run at a .csproj (or a folder/'.' containing a single executable csproj) to build and launch a WinUI app, supporting both packaged and unpackaged apps. Folder mode is unchanged. - Input model + folder/project routing with multi-csproj ambiguity error - Build integration + MSBuild property resolution (JSON/scalar --getProperty) - Unpackaged launch via RunCommand exe with reused Windows App Runtime install - Packaged launch via loose-layout registration + AUMID - Arch-correct runtime install with Framework+DDLM presence check - New AppLauncherService.LaunchExecutable for direct exe launch - Docs (usage, dotnet guide, setup skill), unpackaged WinUI sample, tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Contributor
Build Metrics ReportBinary Sizes
Test Results✅ 1678 passed, 1 skipped out of 1679 tests in 438.8s (+67 tests, +16.4s vs. baseline) Test Coverage❌ 18.4% line coverage, 37.9% branch coverage · ✅ no change vs. baseline CLI Startup Time39ms median (x64, Updated 2026-07-11 06:03:39 UTC · commit |
Start-Process -NoNewWindow launches via CreateProcess, which cannot
execute the npm winapp.cmd batch shim directly and fails with Win32
error 193 ("%1 is not a valid Win32 application") on CI runners where
winapp on PATH is a .cmd. Route the PATH-winapp case through cmd.exe (a
real Win32 host) so the shim runs; cmd /d /c still propagates winapp's
exit code. Product/project-mode launch code is unaffected.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Start-Process -Wait blocks until the launched process AND all its descendants exit. In project mode, winapp launches the unpackaged app as a descendant that runs indefinitely, so -Wait never returns and the test step hangs (previously masked on CI by the earlier Win32 error 193, which failed the launch before -Wait was reached). Drop -Wait and instead wait only on winapp's own process via $proc.WaitForExit(), which does not wait for descendants. --detach makes winapp return as soon as it has built and launched the app, so this captures winapp's exit code without waiting on the app. Verified end-to-end against a winapp.cmd shim (reproducing the CI condition): the launch returns 0 after the build, the app is found by name and stays alive past 3s, and the script completes without hanging. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Addresses PR #612 review findings on the highest-risk runtime + launch paths. H1 (arch-correctness): the reused Windows App Runtime install is now truly arch-correct with a real presence gate. WorkspaceSetupService.InstallWindows- AppRuntimeAsync separates the loose-layout/inventory arch (resolved target arch, used for the win10-<arch> path and ParseMsixInventoryAsync) from the nullable skip-filter arch, and a new IsWindowsAppRuntimeRegistered(arch) checks for BOTH a Framework package (Microsoft.WindowsAppRuntime.*, excluding .CBS.) AND a DDLM (Microsoft.WinAppRuntime.DDLM.*), arch-filtered — mirroring WinAppSDK's IsRuntimeRegisteredForCurrentUser. EnsureWindowsAppRuntimeInstalledAsync now throws if the target-arch runtime is still absent after install instead of treating a missing runtime dir as success, so a cross-arch run no longer skips the install and crashes at bootstrap. L2 (folder mode unchanged): the process-arch handling is scoped so folder mode passes a null arch filter — byte-for-byte identical to the pre-feature behavior. M3 (PID-reuse / exit-code race): LaunchExecutable now returns an owned ILaunchedProcess wrapper (new ILaunchedProcess.cs) instead of a bare pid, so the unpackaged wait/detach/debug flow holds the real Process handle and reads its exit code directly rather than re-attaching via Process.GetProcessById (which could hit a reused pid or an already-exited process wrongly reporting 0). Adds WorkspaceSetupServiceRuntimeGateTests and updates the fakes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…2, M4, M5) Addresses PR #612 review findings on project-mode property resolution. H2 (--json unparseable): stdout in --json mode is now pure JSON. The human- readable "Building..." banner is gated behind !Json and dotnet build diagnostics on a failed build are routed to stderr, so a --json consumer's stdout parses. Adds banner-suppression tests (json vs non-json). M1 (arg escaping): BuildDotnetArguments uses WindowsCommandLine.JoinArguments instead of a hand-rolled QuoteIfNeeded that mishandled trailing backslashes. M2 (--no-build precedence): user -p properties are emitted before the dedicated -c/--arch/-f flags on the --no-build path too, so the dedicated flag wins last (matching the build path and WarnOnOverriddenFlags). Adds a precedence test. M4 (getProperty parse robustness): MsBuildPropertyReader scans each '{' and uses Utf8JsonReader/TryParseValue, tolerating leading diagnostics/preamble and trailing content, and only accepts a Properties object — so a single-property scalar that merely contains '{' is no longer misread as JSON. Adds parser tests. M5 (multi-project disambiguation): adds coverage for a test project (IsTest- Project=true) being excluded from the executable set and for the no-executable-among-multiple ambiguity error. The evaluated OutputType remains the source of truth downstream (BuildAndResolveAsync rejects non-runnable OutputType), with the static parse only used to disambiguate at input time. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Addresses PR #612 review findings. M8: regenerates src/winapp-npm/src/winapp-commands.ts from the current CLI schema so it includes the run project-mode options (--arch, --no-build, --no-restore, -r/--runtime, -c/--configuration, -f/--framework, -p). The generate-commands --check gate now passes. H3: samples/winui-app/test.Tests.ps1 registered a loose-layout package then deleted its backing files without unregistering, polluting the machine/CI. The AfterAll now reads the identity Name from Package.appxmanifest and removes the package (Get-AppxPackage | Remove-AppxPackage) before deleting temp/bin/obj. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds BuildAndResolveAsync unit tests that lock down the spec §7.1 packaged-vs- unpackaged detection at the service level: - WindowsPackageType=None resolves to Unpackaged with the RunCommand apphost .exe as the launch target and SelfContained=false. - Empty WindowsPackageType with EnableMsixTooling=true (the --no-build evaluate- only path, where MSIX targets don't run) falls back to Packaged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds a BuildAndResolveAsync test proving that in --json mode a failed build's captured dotnet diagnostics do not reach stdout (they go to stderr) and the exit code propagates, so a --json consumer's stdout stays parseable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
M5 (behavioral): multi-.csproj disambiguation now classifies each
candidate via an MSBuild evaluation of OutputType/IsTestProject
(--getProperty) so projects whose type comes from an import
(Directory.Build.props, SDK defaults, the test SDK) are detected
correctly, instead of a static XML parse that could silently run the
wrong project. Falls back to the static parse when the SDK/restore is
unavailable. ResolveInput -> ResolveInputAsync. Folder mode still
performs NO evaluation, so it stays byte-for-byte identical.
M6 (tests): drive BuildAndResolveAsync + CheckSdkAsync through
FakeDotNetService -- non-exe OutputType throw, empty TargetDir throw,
unpackaged-missing-RunCommand throw, --no-build hint, happy-path arch
resolution; SDK not-found / non-zero / too-old / capable / newer /
unparseable. One case feeds a '{'-containing build preamble to lock in
the M4 getProperty-parse fix.
M7 (tests): WorkspaceSetupServiceInventoryTests pins
ParseMsixInventoryAsync to the TARGET arch (not the host) and asserts a
missing/empty inventory does not report success -- the gate behind H1.
L3: --property arity ZeroOrMore -> OneOrMore (rejects a bare -p) plus a
handler-side Name=Value check (rejects a value with no '='). +2 tests.
L4: add winui-unpackaged-app and winui-app rows to the README sample
table.
L5: extract Fail(message, isJson) and convert the repeated
log-error + PrintJson + return-1 sites in RunCommand project mode.
Regenerate docs/cli-schema.json for the --property arity change.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Locks in the M3 fix (owned ILaunchedProcess handle, waited via WaitForLaunchedProcessAsync). Drives the unpackaged wait/exit-code path without --detach and asserts a non-zero app exit (42) is reported as a failure rather than masked as success, the owned handle is disposed, and a normally-exiting app is not killed. FakeAppLauncherService.FakeExitCode was previously unused, so this closes the gap. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Closes the remaining M7 sub-gap: GetInstalledVersion/IsPackageInstalled apply their arch filter based on MapArchitecture(architecture). The live WinRT PackageManager comparison can't be faked, but the decision of whether to filter at all is entirely MapArchitecture's null-ness — so expose it as internal and pin: - known arch strings (x64/arm64/x86, case/space-insensitive) map to the WinRT ProcessorArchitecture used for project-mode arch-correctness - null/empty/whitespace/unrecognized => null => NO filter, which is how folder-mode run (architecture == null) keeps matching any installed package incl. Neutral-arch ones (spec L2, #1 hard constraint). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Hardening + test/DX follow-ups from the round-2 pr-review (0 Crit / 0 High / 3 Med / 6 Low, none blocking). Folder mode stays byte-for-byte identical. Fixed: - R2-M1 (med): version-specific runtime gate. InstallWindowsAppRuntimeAsync now also returns the versioned Framework/DDLM identities from the resolved inventory; IsWindowsAppRuntimeRegistered takes optional expected names and, in addition to the generic Framework+DDLM presence check, requires each expected identity for the arch. Closes the false-pass where a DIFFERENT WinAppSDK version registered for the arch masked a silently-failed version-specific install (unpackaged path only; empty/null = legacy behavior). - R2-M2 (med): FakeMsixService gains an EnsureRuntimeInstalledException hook + a RunCommandProjectModeTests case asserting a runtime-prep throw aborts with a non-zero exit and never launches the app. - R2-M3 (med): generate-commands.mjs emits string | string[] for array-typed named options (C# Option<T[]>) and pushes one flag per element, so run's repeatable -p/--property is usable from the npm SDK. Regenerated winapp-commands.ts. - R2-L1: replace the tautological CBS assertion with a real-name discrimination test (.CBS. matches the CBS component name, not a Framework name). - R2-L2: WarnOnOverriddenFlags now warns when a user -p:Platform overrides the --arch-derived Platform (opposite precedence to Configuration/RID). - R2-L4: reworded --runtime help to clarify only the RID's architecture is used; hand-synced docs/cli-schema.json + setup skills (version unchanged). Won't-fix (documented): - R2-L3: valueless -p is rejected by the OneOrMore parser as a standard CLI error; parser-level errors are non-JSON framework-wide (consistent with L5). - R2-L5/L6: pre-existing shared-command gaps, not introduced by this PR. Build: Release warnings-as-errors clean. Tests: 1667 pass / 1 env-skip (+4 new). npm format/lint/compile + generate-commands:check green. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The repeatable --property/-p option used OneOrMore arity, so a bare '-p' (no Name=Value) failed as a System.CommandLine parser arity error. Parser errors are rendered as plain text by the shared invocation path, so under --json callers got no structured error (only text + exit 1). Move the valueless-'-p' rejection into the run handler so it flows through the command's own --json error envelope: - RunCommand.cs: relax --property arity OneOrMore -> ZeroOrMore, then detect a valueless occurrence from the raw OptionResult (IdentifierTokenCount > Tokens.Count -- one identifier token per '-p', so more identifiers than captured values means at least one '-p' had no argument) and route it through Fail(), which emits the JSON error envelope under --json. This is local to run and does not touch the shared parser path (the pre-existing L5/L6 gaps). It also fixes OneOrMore's greedy '-p --detach' consumption (a bare '-p' no longer swallows the following option token). - cli-schema.json: sync run --property arity minimum 1 -> 0 to match the ZeroOrMore the AOT CLI now emits (validate-docs regenerates + compares). Tests (RunCommandProjectModeTests): - ProjectMode_ValuelessProperty_Errors: now asserts the handler-level failure. - ProjectMode_ValuelessProperty_Json_EmitsJsonError: new; asserts a valueless -p under --json yields a JSON envelope with an Error field. - ProjectMode_RepeatableProperty_Succeeds: new; guards the repeatable-'-p' happy path against regression. Folder mode is unaffected: without -p the option result is null and the new check is skipped, so behavior is byte-identical. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round-3 re-review of the project-mode runtime presence gate surfaced two residuals of the now-fixed R2-M1 version-specific gate. Both are in PR-introduced code, so fix them to reach zero findings. M1-residual (MEDIUM): the gate required each expected Framework/DDLM identity to be *present by name* (StartsWith), but the Framework family name is only `major.minor`. A stale OLDER patch of the same minor (e.g. 1.8.A registered, app built against 1.8.B whose install silently failed) satisfied the name check and false-passed the gate -> possible MinVersion crash at bootstrap. Thread the required version alongside the name and require `GetInstalledVersion(name, arch)` to parse and be >= the required version. Lenient presence fallback when either version string is unparseable. Folder-mode / legacy callers pass no expected list -> generic presence check only, byte-identical. - InstallWindowsAppRuntimeAsync now returns IReadOnlyList<(string Name, string Version)> RuntimePackages (was IReadOnlyList<string>), carrying the inventory NewVersion per identity. - IsWindowsAppRuntimeRegistered takes IReadOnlyList<(string Name, string Version)>? and compares versions. - Updated IWorkspaceSetupService, MsixService.Identity (public + private overloads), and FakePackageRegistrationService (per-name/arch version hook, records arch). L1-residual (LOW): when GetRuntimeMsixDirAsync returns null the expected identity list is empty, so the gate falls open to the generic prefix check. This path is project-mode unpackaged (non-self-contained), where a framework-dependent app always needs a runtime, so surface a loud (non-verbose) warning that the exact runtime couldn't be version-verified. The generic gate still fails closed when nothing is registered at all. Tests: version-aware gate cases in WorkspaceSetupServiceRuntimeGateTests (older-patch -> false, newer-patch -> true, updated present/different- version cases assert via GetInstalledVersion + arch forwarding). Full C# suite 1671 pass / 1 pre-existing env-skip; Release warnings-as-errors 0W/0E. No public CLI surface / schema / npm change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round-4 pr-review flagged the unpackaged runtime presence gate as over-strict on the DDLM identity. Because DDLM package names embed the FULL version (e.g. Microsoft.WinAppRuntime.DDLM.8000.806.2252.0-x64) and install side-by-side, the per-identity exact-name GetInstalledVersion lookup effectively demanded the app's EXACT DDLM. If that specific DDLM's install silently failed while a newer compatible DDLM for the same framework minor was present, the gate would false-FAIL an otherwise launchable app. Fix: apply the exact-identity + version>=required check to the app-facing Framework family only; rely on the generic DDLM prefix-presence check (already run at the top of the gate) for the DDLM. The Framework version compare remains the authoritative patch-level guard (still closes the R2-M1 stale-older-patch false-pass); DDLMs track the Framework, so a present DDLM plus the correct Framework version is sufficient. - WorkspaceSetupService: extract IsFrameworkGatePackageName helper (reused by IsRuntimeGatePackageName); skip non-Framework (DDLM) entries in the per-identity loop with a rationale comment. - IWorkspaceSetupService / IsWindowsAppRuntimeRegistered docs updated to describe Framework-exact vs DDLM-generic matching. - Tests: +2 (older expected DDLM not registered + newer present + Framework ok -> gate TRUE and DDLM NOT exact-looked-up; Framework-vs-DDLM prefix discrimination). Full suite 1673 pass / 1 env-skip; Release 0W/0E. Folder mode byte-identical (no expected list -> loop skipped entirely). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements
.csproj"project mode" forwinapp runper the committed specspecs/winapp-run-csproj.md(v0.4).winapp run <MyApp.csproj>— orwinapp run ./ a folder containing a single executable.csproj— now builds the WinUI project and launches it in one step, supporting both packaged and unpackaged WinUI apps. Existing folder mode is unchanged (all new options are optional; project mode is a new branch keyed on the input pointing at / containing a top-level buildable.csproj).What's implemented (per spec §12 phases)
DirectoryInfo→FileSystemInfo;ResolveInputclassifies Folder vs Project; multi-.csprojambiguity error (prefers the evaluatedOutputTypeover the static XML parse inProjectDetectionService).dotnet build <csproj> -t:Build --getProperty:...(with the required-t:Build);MsBuildPropertyReaderhandles both the JSON (multi-prop) and raw scalar (single-prop) shapes; repeatable-p/--propertyforwarded to both build and evaluation; dedicated-flag-beats--pprecedence mirrored.WindowsPackageType(None⇒ unpackaged); launches the apphostRunCommandexe directly via newAppLauncherService.LaunchExecutable; reuses the existing WinAppSDK runtime install (EnsureWindowsAppRuntimeInstalledAsync), gated onWindowsAppSDKSelfContained.TargetDir+ AUMID launch (reuses the folder-mode pipeline); guardrail error when a project resolves to packaged but has noPackage.appxmanifest.WorkspaceSetupService.InstallWindowsAppRuntimeAsyncparameterized by the app's resolved--arch(was CLI-process arch only), with a Framework+DDLM presence check (GetInstalledVersion(name, architecture)) to avoid needless reinstalls.Scope guards: .NET-only; no
--project/--packaged/--unpackagedflags. Unpackaged runs reject identity-only options (--manifest,--no-launch,--with-alias,--clean,--unregister-on-exit,--output-appx-directory).--getPropertyavailability probed via runtime capability (SDK 8.0.100+), erroring clearly otherwise.New CLI options (project mode only; ignored in folder mode)
-c/--configuration(defaultDebug),--arch,-r/--runtime,-f/--framework,--no-build,--no-restore,-p/--property(repeatable).Test coverage
C# unit/routing (144 pass, Release):
MsBuildPropertyReaderTests— JSON/scalar/preamble/empty/case/missing-prop parsing.RunArchHelperTests— arch normalization, RID/Platform mapping.ProjectRunServiceTests—BuildDotnetArguments(default/arm64/--no-build/user--p:Platformsuppression/framework), SDK-version parsing,ResolveInputincl. multi-csproj ambiguity.RunCommandProjectModeTests(13) — unpackaged launch + runtime-arch, arm64, self-contained skip, identity-option rejection, forced-unpackaged property forwarding, packaged AUMID + arch, packaged-no-manifest guardrail, SDK-too-old, build-fail exit-code propagation, invalid-arch, resolve ambiguity, folder-mode regression.RunCommandTests— project-mode option parsing +TryResolveArchitecture; existing folder-mode tests unchanged.Pester sample & guide (spec §10):
samples/winui-unpackaged-app: new minimal unpackaged WinUI app; test builds +winapp run .and asserts the app boots off the reused runtime install (verified locally: app process stays alive). Also asserts evaluatedWindowsPackageType == None.samples/winui-app(packaged): project-modewinapp run . --no-launchfrom a clean copy + evaluatedWindowsPackageType != None..github/workflows/test-samples.ymlmatrix.Docs / samples updated
docs/usage.md(run section: folder-vs-project auto-detection + project-mode subsection),docs/guides/dotnet.md,docs/fragments/skills/winapp-cli/setup.md(hand-written template; generatedSKILL.md/cli-schema.json/.clauderegenerated viagenerate-llm-docs.ps1), newsamples/winui-unpackaged-app.Deferred / notes
dotnet runhook stays folder-based — unchanged, as decided.build-cli.ps1were not run locally: the AOT publish needs the MSVC linker env (missing in this sandbox) and npmbin/blobs are gitignored/CI-produced. The CLI + tests build clean in Release (0 warnings / 0 errors, warnings-as-errors on), andcli-schema.json/ skills were regenerated with the correct0.4.1version. CI's Build & Package workflow will produce the AOT artifacts.Co-authored-by: Copilot App 223556219+Copilot@users.noreply.github.com