From c7f4f46f8415a6967cbd6cccecf0d6943ddda4d9 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Wed, 21 Oct 2026 21:28:31 -0500 Subject: [PATCH] Add one-off acceptance kit and portable regression fixes --- AGENTS.md | 3 + agent_docs/one-off-acceptance-summary.md | 49 +++ agent_docs/tui-pty-testing.md | 26 +- docs/one-off-testing-agent.md | 135 ++++++++ docs/one-off-testing.md | 140 +++++++++ docs/usage.md | 8 +- .../Ui/SkillViewAppIntegrationTests.cs | 36 +++ .../Maintenance/GhSharedLockGuardTests.cs | 14 +- .../WindowsPayloadMetadataTests.cs | 71 +++++ tools/test_once.py | 290 ++++++++++++++++++ tools/tests/test_test_once.py | 182 +++++++++++ 11 files changed, 944 insertions(+), 10 deletions(-) create mode 100644 agent_docs/one-off-acceptance-summary.md create mode 100644 docs/one-off-testing-agent.md create mode 100644 docs/one-off-testing.md create mode 100644 tools/test_once.py create mode 100644 tools/tests/test_test_once.py diff --git a/AGENTS.md b/AGENTS.md index 6b63283d..6d827352 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -974,6 +974,9 @@ the terminal, with both a full-screen TUI and scriptable CLI commands. unchanged saved object back to SetAccessControl can write nothing. Restore parent then payload then file after inheritance propagation, and avoid changing the source owner when testing read-only backup permissions. + Fixture owner normalization must inspect the no-follow object and skip an + already-current-user owner. Redundant owner-only `SetSecurityInfo` can strip + inherited OWNER RIGHTS access; retain file/directory/link regression coverage. - Every Unix descriptor duplication must atomically retain close-on-exec through `F_DUPFD_CLOEXEC`; never use plain `dup` followed by `F_SETFD`. Darwin ARM64 diff --git a/agent_docs/one-off-acceptance-summary.md b/agent_docs/one-off-acceptance-summary.md new file mode 100644 index 00000000..4091f9bc --- /dev/null +++ b/agent_docs/one-off-acceptance-summary.md @@ -0,0 +1,49 @@ +# One-off acceptance summary + +PR #86 adds manually invoked testing tools, fixture corrections and startup +documentation. It changes no product source or dependency locks. Personal +reports, machine dumps, command logs and terminal captures are excluded from +the PR and its commit history. This record contains aggregate results only. + +## Fixes retained + +- The runner uses CI-compatible sequential locked AOT publishes, rejects result + directories beneath any ancestor `.git` marker, and reports partial or + interrupted coverage explicitly. +- The collision-guard fixture isolates Git context so a short output budget + exercises the intended listing rather than an ambient checkout path. +- Windows fixture owner normalization avoids redundant owner writes that can + remove inherited OWNER RIGHTS access. File, directory and link regressions + failed with the original helper and passed with the correction. +- Startup documentation explains the inventory-driven transition to Installed. + Integration tests exercise initial Discover query input at two terminal sizes. + No product focus change was needed. + +## Verification + +| Coverage | Result | Limit | +| --- | --- | --- | +| Combined WSL unit suite | 2,698 passed, 30 explicitly skipped, 0 failed | Windows-only test bodies that return early do not establish Windows coverage. | +| Combined WSL integration suite | 11 passed | ANSI-driver tests do not establish native Windows terminal behavior. | +| Python runner smoke tests | 10 passed | Runner contracts, not product acceptance. | +| Combined Release builds and scoped formatting | Passed | No dependency lock changes. | +| Focused macOS collision/startup tests | 6 passed | Focused confirmation rather than a repeated full suite. | +| Settled-startup macOS PTY checks | 2 passed; complete queries and successful Ctrl+Q exits | Early input during delayed inventory was not tested. | +| Windows fixture regressions | 3 passed after failing with the original helper | Focused native verification. | +| Windows metadata test class | 21 passed | Includes refusal paths; does not prove complete SACL publication. | +| Supplemental native AOT CLI proofs | 16 passed on each of Windows and macOS | Original proof matrices were not repeated after test-only corrections. | + +## Remaining release acceptance + +- Verify positive Windows security-metadata-dependent retention and unpin + publication in a context that already has `SeSecurityPrivilege`. Restricted + runs failed those positive assertions; passing refusal paths are not a waiver. +- Complete a native Windows terminal walkthrough for search, install, reviewed + update, Undo, cleanup and keyboard quit. Process startup alone is insufficient. +- A full Windows suite pass after the fixture correction remains unverified. + An earlier suite timed out; a later diagnostic run completed with failures, + and the focused follow-up did not repeat the full suite. + +These gaps remain release follow-ups, separate from merging the testing kit. +Required PR checks must pass before merge. Future device results stay outside +Git; retain only sanitized aggregate conclusions and durable regression tests. diff --git a/agent_docs/tui-pty-testing.md b/agent_docs/tui-pty-testing.md index 97a0fe64..500b6a4d 100644 --- a/agent_docs/tui-pty-testing.md +++ b/agent_docs/tui-pty-testing.md @@ -109,12 +109,24 @@ immediately. ## Key PTY lessons from this repo -### 1. Startup focus is in the query field +### 1. Wait for startup inventory before judging focus -At startup, plain letters go into the query field. If you want single-letter -global shortcuts like `d`, `u`, `c`, or `I`, first send `Esc`. `Ctrl+Q` is the -unconditional quit path and works without leaving the field; plain `q` quits -only when a top-level read-only view has focus. +Startup initially paints Discover. When the first inventory contains skills, +SkillView can automatically open Installed, unless the user has already +interacted. The first Discover frame and its initial `ready` status do not prove +that startup inventory has finished. Wait for the environment/inventory result +and inspect the active view before sending keys. + +With an empty inventory, Discover starts with query focus. From the Installed list, +press `/` to open Discover and focus its query. Typing `f` while the Installed +list has focus opens its filter; that shortcut is consumed rather than inserted. +Do not interpret the remaining filter text as a lost Discover query without +checking which view had focus when the key arrived. + +In the Discover query, printable keys enter text. To use single-letter global +shortcuts like `d`, `u`, `c`, or `I`, first leave the field. `Ctrl+Q` is the +unconditional quit path and works without leaving it; plain `q` quits only when +a top-level read-only view has focus. In a baseline headless PTY, Terminal.Gui can hold a bare Esc byte and combine it with the next key as Alt+key, even after a delay. Use Tab to leave the query @@ -136,8 +148,8 @@ Screen text can mislead because: - result tables truncate repo names - old frames remain in the PTY buffer -For startup smoke detection, wait for the `Search:` field rather than relying -on a transient status message. +For Discover readiness, wait for the `Search:` field after explicitly entering +Discover. Startup may legitimately settle on Installed instead. ### 3. Modal screens need unique wait conditions diff --git a/docs/one-off-testing-agent.md b/docs/one-off-testing-agent.md new file mode 100644 index 00000000..72facb27 --- /dev/null +++ b/docs/one-off-testing-agent.md @@ -0,0 +1,135 @@ +# Agent task: one-off release-candidate acceptance + +This is a bounded testing task for Codex or Claude Code on one machine. Follow +the platform prompt in [one-off-testing.md](one-off-testing.md). Test the supplied +full commit SHA. Do not pull more features from PR #73, change product code, +weaken tests, change lock files, merge PRs, or establish recurring automation. + +## Prepare and run + +1. Read `AGENTS.md`. Create a fresh dedicated clone/worktree at the requested + commit, outside any dirty development checkout. Fetch the `v1.0.1` tag if + missing. Record the SHA and `git status --porcelain`; stop on a mismatch or + dirty source. Do not stash, reset or clean someone else's work. +2. Check Git, Python 3.9+, gh >=2.97.0 and the exact SDK in `global.json`. + Record OS, filesystem, terminal and architecture. If a prerequisite is + missing, install only those prerequisites from official sources into user-owned + locations when your session permits. Use a private SDK installation rather + than changing the machine's default SDK. If setup requires administrator access + or a lengthy native-toolchain installation, report the exact commands needed + and continue independent checks. Keep project dependencies unchanged. +3. Run the Python smoke tests, then `tools/test_once.py --expect-commit SHA + --live` with the appropriate Python command. Add `--dotnet PATH` if needed. + Add `--aot` only if the native build toolchain is already available for a + committed release RID. Use existing gh auth; never print/copy tokens into + commands, logs, reports or committed files. No auth means remote checks are + blocked, while offline checks can still run. +4. Read every command result and test summary, including skipped tests. Keep + failures visible. A runner exit of 0 establishes command success, not that + all platform-specific tests or UX checks ran. Record which host binaries + were actually exercised and whether they were managed or AOT. + +Let the automated checks finish with their existing timeouts. Then spend at +most 30 minutes on the terminal checks below and 15 minutes reproducing new +failures. Stop investigating a failure after one focused reproduction unless +the next check is necessary to distinguish a fixture error from a product bug. +Do not wait for CI or another reviewer to finish this local report. +Avoid broad code reviews or reading the whole repository. Read only the guides +and failure-specific code needed for this pass. Use an inexpensive model for +command execution and log collection if available, reserving stronger models +for diagnosis. Do not create a swarm of review agents or repeatedly poll CI. + +## Drive the actual terminal + +Read [the PTY guide](../agent_docs/tui-pty-testing.md) before launching the built +host reported in `report.json`. Use a real PTY and terminal emulator/probe +responses on WSL/macOS, or a native Windows terminal/ConPTY on Windows. +Redirected CLI output, direct key-handler calls and a WSL session do not prove +native Windows TUI behavior. If the available agent tools cannot drive the real +terminal, mark that coverage blocked and provide a short manual checklist. +Do not build a new terminal automation framework for this pass. + +Use a separate private fixture workspace, HOME/USERPROFILE, config, state and +temp directories. Strip inherited `GIT_*` and agent overrides as described in +AGENTS.md; keep the parent process environment unchanged. Capture existing auth +privately before isolating HOME when a remote operation requires it. Never +apply, remove, unpin or undo against actual user installations. Do not launch +AI clients to populate effective-loading evidence. + +Start at **80×24**, then repeat any clipped or awkward view at a larger size. +Use help/current documentation for keys rather than guessing mutation flags. +After each mutation, check payload hashes, link targets, owner/registry evidence +and unchanged independent copies from the shell. Capture meaningful screen +states and terminal identity. Distinguish fixture creation from actions actually +performed through the TUI. + +Prioritize these user journeys, in this order: + +| Journey | Evidence to collect | +| --- | --- | +| Start, browse, search, preview | Wait for startup inventory to finish. An empty fixture stays in Discover with query focus; an existing inventory can open Installed. From the Installed list, press `/` to open Discover and focus its query. Verify the whole typed query, Tab, shortcuts, help and quit at 80×24. Search failure has a clear explanation when offline. | +| Install and inspect copies | Install into the fixture using actual gh; inspect two independent copies with differing content and, where supported, a shared link. Explain which physical copy and consumers are selected. Refresh preserves correct scope/workspace context. | +| Review a version/update | Use Updates, review a specific existing tag, cancel first, then approve retention and apply one target. Check exact source/version and local-change messaging, queue/focus behavior, and that independent peers stay unchanged. A pinned fixture must remain protected until separately reviewed unpin. | +| Undo | Inspect the saved operation, cancel, then approve the exact reviewed Undo and verify the original. If time permits, edit after inspection and confirm stale approval refuses. Check refresh and retry guidance. | +| Cleanup | Show an empty leftover directory and a broken link beside a healthy skill. Review/cancel first, then apply the safe fixture cleanup. Verify healthy content and independent copies remain; deliberately review-only items should be explained rather than counted as attempted destructive failures. | + +Use [usage](usage.md) and [saved updates](saved-updates.md) for current behavior. +Avoid a large matrix of every supported agent or every adversarial native case; +the automated suites/proofs cover those contracts. Inspect static package-check +and effective-loading output if convenient, but do not displace the journeys +above or install real AI clients just to test them. + +On WSL/macOS, the existing +`agent_docs/evidence/prove-reviewed-update-tui.py` can automate a live reviewed +update journey; inspect its `--help` and retain its captures. It uses POSIX PTYs +and is not a Windows proof. Run it with a new output directory and the built +host; it creates private fixtures. Report the exact shortcut/disposition tested. +Do not rerun every variant automatically. On Windows, if symlink privileges or +security-metadata visibility are unavailable, record the refusal and missing +coverage; do not elevate or bypass guards to manufacture a pass. + +## Return a useful report + +Write `AGENT-REPORT.md` beside the runner's `REPORT.md`, outside the checkout. +Use this shape: + +```markdown +# SkillView acceptance — WINDOWS / WSL / MACOS +Commit: FULL_SHA +Machine: OS, architecture (hardware/process/SDK), filesystem, terminal, SDK, gh +Builds: managed/AOT, standalone/extension paths +Overall: PASS / FAIL / PARTIAL / BLOCKED + +## Automated checks +Commands and logs; actual test totals and skipped counts/reasons. +Offline and remote proofs: passed/failed/blocked, separately. +AOT and platform-specific/native coverage actually exercised or missing. + +## Terminal journeys +Each journey: passed/failed/not-run/blocked; actual terminal size and input path. +Mutation evidence and unchanged-peer checks; relevant captures/transcripts. + +## Findings, most important first +Product / environment / fixture / authentication or network. +Exact reproduction, expected/actual behavior, log/capture paths. +Any risk of wrong target, lost local edits, misleading approval or missing Undo. +Say 'pre-existing' only with baseline reproduction evidence. + +## Remaining manual checks +Only the specific checks that could not be automated and why. +``` + +If there is a new failure, reproduce it once in a fresh fixture. If comparing to +the release is necessary, use a separate checkout and clearly label that result; +never change the requested candidate checkout mid-run. Preserve original failing +logs even if the retry passes. Do not hide skips, retry until green, waive unknown +native failures, or treat authentication/rate-limit failures as successful tests. + +Keep reports, logs, machine dumps and captures outside Git; do not commit personal +or device evidence. Return a sanitized summary with aggregate results and missing +coverage, omitting usernames, hostnames, absolute local paths and device +identifiers. Keep investigation paths in the local report. Share raw evidence +separately only when explicitly requested, after checking it for personal data +and credentials; exclude tokens, private homes, state archives and package caches. +Leave fixtures available for investigation. Report any owned process that could +not be stopped. diff --git a/docs/one-off-testing.md b/docs/one-off-testing.md new file mode 100644 index 00000000..d8e5bb1a --- /dev/null +++ b/docs/one-off-testing.md @@ -0,0 +1,140 @@ +# Test this release candidate on three machines + +This is a one-time acceptance pass after the changes since `v1.0.1`. Run one +Codex or Claude Code session on each machine, in parallel. Each session tests the +same commit and produces logs plus a short report. There is no scheduled job or +service to set up. + +Copy one of the prompts below. Replace `TEST_SHA` with the **same full commit +SHA** on all machines, including these testing files. Give the agent a fresh +clone or dedicated worktree; keep your development checkout as it is. + +## Windows 11 prompt + +```text +Do a one-off SkillView release-candidate test on native Windows 11. +Repository: https://github.com/harder/gh-skillview +Exact commit: TEST_SHA +Use a fresh dedicated checkout at this commit, with the v1.0.1 tag available. +Read AGENTS.md and docs/one-off-testing-agent.md and execute that test task. +Use native Windows Python, .NET, gh and a native Windows terminal. WSL results +do not count as Windows results. Use py -3 for the Python runner. +Do not develop features, fix product code, or set up recurring automation. +Return a sanitized summary and local paths to logs/captures, with failures and +blocked coverage stated plainly. Do not commit personal reports or device evidence. +``` + +## WSL Ubuntu prompt + +```text +Do a one-off SkillView release-candidate test in WSL Ubuntu. +Repository: https://github.com/harder/gh-skillview +Exact commit: TEST_SHA +Use a fresh dedicated checkout at this commit, with the v1.0.1 tag available. +Read AGENTS.md and docs/one-off-testing-agent.md and execute that test task. +Keep the checkout and fixtures in the Linux filesystem, not /mnt/c. Use Linux +Python, .NET and gh; record the WSL/kernel version and whether openat2 works. +Use python3 for the runner. Record /mnt/c behavior only as an additional probe. +Do not develop features, fix product code, or set up recurring automation. +Return a sanitized summary and local paths to logs/captures, with failures and +blocked coverage stated plainly. Do not commit personal reports or device evidence. +``` + +## MacBook prompt + +```text +Do a one-off SkillView release-candidate test on this MacBook. +Repository: https://github.com/harder/gh-skillview +Exact commit: TEST_SHA +Use a fresh dedicated checkout at this commit, with the v1.0.1 tag available. +Read AGENTS.md and docs/one-off-testing-agent.md and execute that test task. +Use python3 for the runner. Record hardware, process and .NET architectures. +On Apple Silicon use native ARM64, not Rosetta. On Intel report native operations +that correctly refuse unsupported ABI; do not bypass the platform checks. +Do not develop features, fix product code, or set up recurring automation. +Return a sanitized summary and local paths to logs/captures, with failures and +blocked coverage stated plainly. Do not commit personal reports or device evidence. +``` + +## What the agent will run + +Prerequisites are Git, Python 3.9+, the exact .NET SDK from `global.json`, and +`gh` 2.97.0+. Existing GitHub authentication enables the remote proofs. Agents +should check what is already installed before installing anything. Missing +prerequisites may be installed from official sources into user-owned locations +when the session permits; lengthy native-toolchain setup can be reported instead. +The checked-out commit must be clean, including untracked files. +The output/temp fixture location must also be outside **every** ancestor `.git` +marker. Even an empty `.git` directory, invalid gitfile or dangling marker link +changes product containment policy. The runner rejects such locations before +creating fixtures; choose another `--output` without removing the marker. + +On WSL/macOS, from the dedicated checkout: + +```sh +python3 -m unittest discover -s tools/tests -v +python3 tools/test_once.py --expect-commit TEST_SHA --live +``` + +On Windows PowerShell: + +```powershell +py -3 -m unittest discover -s tools/tests -v +py -3 tools/test_once.py --expect-commit TEST_SHA --live +``` + +Use `--dotnet /absolute/path/to/dotnet` if the required SDK host is outside +PATH. That host's runtime must also be discoverable by built apphosts; set +`DOTNET_ROOT` to its installation directory if necessary. Add `--aot` when the +native compiler/linker prerequisites are already installed. AOT publishes the +two products sequentially and tests those binaries instead of managed hosts. +Without it, report native AOT coverage as not run. Supported release RIDs are +Windows x64/ARM64, Linux x64 and macOS ARM64; other hosts can run managed checks. +Native publish uses CI's locked restore recipe; do not run a separate +`dotnet restore -r RID`, which overrides the four-RID set in the committed locks. + +The runner performs locked restores, Release builds, the full unit and integration +suites, and existing CLI proofs for package inspection, native identity, +gitfile protection, inventory filtering, discovery and quarantine/Undo. With +`--live`, it also exercises real remote install/update/restore/Undo fixtures for +both standalone and extension binaries. It retrieves existing gh authentication +without logging the token. Live proofs use the public repositories selected by +the existing proof scripts; network, authentication and remote changes can block +them independently of product correctness. + +Each subprocess gets private HOME/config/state/temp directories and neutralized +Git/agent overrides. Mutating proof scripts create their own fixture working +directories. Builds use the dedicated checkout and the ordinary NuGet cache. +The runner does not scan or modify your actual installed skills. The agent task +adds real terminal testing; command success alone does not complete acceptance. +On Windows, a private file/directory symlink probe identifies missing fixture +permissions. Affected CLI proofs are marked not run, making coverage partial; +unrelated checks still run. Test-suite failures remain failures to investigate, +including missing security-metadata privileges; the kit does not waive them. + +Results default to a unique temp directory outside the checkout (`/var/tmp` on +Linux). `--output PATH` chooses a new directory instead. The runner prints its +location and writes `REPORT.md`, `report.json`, command logs and proof JSON. +Exit status 0 means requested commands passed, 1 means a command failed, and 2 +means blocked or partial coverage. Test skips still need to be read in the logs. +Each command has a timeout; after a timeout the run stops for cleanup inspection. +Run one kit instance per checkout to avoid shared build outputs racing. +For a timeout investigation, add `--diagnostic-tests`: xUnit reports tests taking +over 60 seconds, and MTP writes diagnostic files beneath the result directory. +This adds evidence, without extending the command timeout or changing assertions. + +The kit requests positive native link metadata proof, as CI does. On Windows, +complete SACL metadata needs an already assigned `SeSecurityPrivilege`; symlink +creation rights alone do not supply it. A restricted test process can therefore +fail those positive assertions while separate CLI workflows pass. Keep those +failures visible, retain the exact refusal and investigate them separately; do +not elevate, disable metadata guards or treat CLI success as a full-suite pass. + +Keep reports, logs, machine dumps and screen captures outside Git. Return a +sanitized summary of results and remaining gaps; omit usernames, hostnames, +absolute local paths and device identifiers. Share raw evidence separately only +when explicitly requested, after checking it for personal data and credentials. +Never upload entire result directories: they also hold private fixture homes and +state. Compare results by commit, platform and failure reproduction, then fix the +confirmed release blockers in a separate change. The aggregate acceptance record +is in [one-off-acceptance-summary.md](../agent_docs/one-off-acceptance-summary.md). diff --git a/docs/usage.md b/docs/usage.md index 20462997..14a1459c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -10,7 +10,7 @@ The TUI is organized around three primary tabs in a persistent top header — ** | Tab / view | What it does | How to open | |---|---|---| -| **Discover** ◇ | Search public skills, refine by owner/agent/limit, preview `SKILL.md`, inspect metadata, flip the right pane between preview and logs, and stage installs. Default landing view. | `1`, click the pill, or `←/→` to cycle | +| **Discover** ◇ | Search public skills, refine by owner/agent/limit, preview `SKILL.md`, inspect metadata, flip the right pane between preview and logs, and stage installs. Opens when no skills are installed. | `1`, click the pill, or `←/→` to cycle | | **Installed** ▣ | Lists installed skills across the local inventory, filter, cycle sort, cycle a pinned/unpinned filter, inspect details, open the folder, or remove. | `2`, click the pill, or `←/→` | | **Changes** △ | Maintenance observations grouped by issue and physical installation, including cleanup review and unchecked installations. Enter opens the exact selected target. | `3`, `u`, click the pill, or `←/→` | | **Doctor** | Full-screen environment report: `gh` path/version, auth state, `gh skill` availability, installed agent homes, and log location. Esc returns to the previous tab. | `d` | @@ -23,11 +23,15 @@ The TUI is organized around three primary tabs in a persistent top header — ** Each tab preserves its own state (filter text, selection, sort, marks) when you switch away and back. +Startup checks the local inventory. If skills are found before you start +interacting, it opens Installed and focuses the list. Otherwise it keeps the +current view. From a list, press `/` to focus the Discover search box. + ### Find and install a skill The main "discover and inspect" loop: -1. Type a search query, and optionally press `f` to narrow the next search with **Owner**, **Agent**, **Limit**, and hidden-dir options. +1. Focus the Discover search box (press `/` from a list), then type a search query. Leave the field and press `f` to narrow the next search with **Owner**, **Agent**, **Limit**, and hidden-dir options. 2. Browse results in the left table; selection drives the detail pane on the right. 3. Press `S` to cycle a sort (stars ↓ → name ↑ → name ↓ → repo ↑ → off). The active sort column's header shows the direction. 4. Press `e` to flip the detail pane between rendered markdown and raw, `o` to open the repo in a browser, or `l` to inspect logs. diff --git a/tests/SkillView.IntegrationTests/Ui/SkillViewAppIntegrationTests.cs b/tests/SkillView.IntegrationTests/Ui/SkillViewAppIntegrationTests.cs index 647dc4b2..2457eeff 100644 --- a/tests/SkillView.IntegrationTests/Ui/SkillViewAppIntegrationTests.cs +++ b/tests/SkillView.IntegrationTests/Ui/SkillViewAppIntegrationTests.cs @@ -13,6 +13,42 @@ namespace SkillView.IntegrationTests.Ui; public sealed class SkillViewAppIntegrationTests { + [Theory] + [InlineData(80, 24)] + [InlineData(120, 40)] + public async Task RunAsync_DiscoverStartupTypingReachesQueryWithoutFocusShortcut(int width, int height) + { + SkillViewApp? skillview = null; + var checkedInput = false; + var options = new AppOptions( + InvocationMode.Standalone, DispatchMode.Tui, false, AppTheme.Default, [], null, []); + skillview = new SkillViewApp(TuiServices.Build(new Logger(LogLevel.Debug)), options, () => + { + var terminal = Application.Create().Init("ansi"); + terminal.Driver!.SetScreenSize(width, height); + terminal.AddTimeout(TimeSpan.FromMilliseconds(20), () => + { + Assert.Equal(SkillViewTab.Discover, skillview!.ActiveTabForTests); + Assert.True(skillview.QueryFieldForTests!.HasFocus, "Discover should initially focus the query input."); + foreach (var character in "focusprobe") terminal.Keyboard.RaiseKeyDownEvent(new Key(character)); + Assert.Equal("focusprobe", skillview.QueryFieldForTests.Text); + Assert.Equal(SkillViewTab.Discover, skillview.ActiveTabForTests); + Assert.True(skillview.UserInteractedSinceLaunchForTests); + checkedInput = true; + terminal.Keyboard.RaiseKeyDownEvent(new Key(KeyCode.Q | KeyCode.CtrlMask)); + return false; + }); + return terminal; + }, probeOnRun: false); + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var exitCode = await skillview.RunAsync(deadline.Token); + + Assert.True(checkedInput); + Assert.False(deadline.IsCancellationRequested); + Assert.Equal(ExitCodes.Success, exitCode); + } + [Fact] public async Task RunAsync_AnsiInstalledList_HandlesRepeatedArrowsBeyondBothEnds() { diff --git a/tests/SkillView.Tests/Maintenance/GhSharedLockGuardTests.cs b/tests/SkillView.Tests/Maintenance/GhSharedLockGuardTests.cs index db9974bc..f13f7de4 100644 --- a/tests/SkillView.Tests/Maintenance/GhSharedLockGuardTests.cs +++ b/tests/SkillView.Tests/Maintenance/GhSharedLockGuardTests.cs @@ -120,8 +120,20 @@ public async Task NamespacedAliasesAndTruncatedListingCannotBypassCollisionGuard File.SetUnixFileMode(fake, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); var marker = Path.Combine(home.FullName, "mutated"); var logger = new Logger(); + // The small output budget targets gh listing, not a possibly long + // checkout path returned by ambient Git project-root discovery. + var privateGit = Path.Combine(home.FullName, ".git"); var runner = new ProcessRunner(logger, maxCapturedCharsPerStream: truncate ? 64 : 4096, - environment: new Dictionary { ["HOME"] = home.FullName, ["SKILLVIEW_TEST_MUTATION"] = marker }); + environment: new Dictionary + { + ["HOME"] = home.FullName, + ["USERPROFILE"] = home.FullName, + ["GIT_DIR"] = privateGit, + ["GIT_WORK_TREE"] = home.FullName, + ["GIT_COMMON_DIR"] = privateGit, + ["GIT_CONFIG_COUNT"] = "0", + ["SKILLVIEW_TEST_MUTATION"] = marker + }, defaultWorkingDirectory: home.FullName); var result = await new GhSkillInstallService(runner, logger).InstallAsync(fake, "different/repo", requested, new(All: requested is null), TestContext.Current.CancellationToken); Assert.False(result.Succeeded); diff --git a/tests/SkillView.Tests/Maintenance/WindowsPayloadMetadataTests.cs b/tests/SkillView.Tests/Maintenance/WindowsPayloadMetadataTests.cs index 24f97bda..2968f6f1 100644 --- a/tests/SkillView.Tests/Maintenance/WindowsPayloadMetadataTests.cs +++ b/tests/SkillView.Tests/Maintenance/WindowsPayloadMetadataTests.cs @@ -202,11 +202,79 @@ public void WindowsResolutionObservesOriginalFileThroughItsInitialHandleAndFreez Assert.Equal(new byte[] { 1, 2, 3 }, bytes.ToArray()); } + [Theory] + [InlineData("file")] + [InlineData("directory")] + [InlineData("link")] + public void AlreadyOwnedFixtureKeepsInheritedOwnerRightsAndNoFollowAccess(string kind) + { + if (!OperatingSystem.IsWindows()) return; + using var identity = WindowsIdentity.GetCurrent(); + var parent = Directory.CreateDirectory(Path.Combine(temporary.FullName, "owner-rights")); + var access = new DirectorySecurity(); + access.SetAccessRuleProtection(true, false); + foreach (var sid in new[] { new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null), new SecurityIdentifier("S-1-3-4") }) + access.AddAccessRule(new FileSystemAccessRule(sid, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); + var path = Path.Combine(parent.FullName, "fixture"); + if (kind == "file") File.WriteAllText(path, "fixture"); + else if (kind == "directory") Directory.CreateDirectory(path); + else Directory.CreateSymbolicLink(path, Directory.CreateDirectory(Path.Combine(parent.FullName, "target")).FullName); + // Normalize elevated runners' Administrators ownership while ordinary + // inheritance still applies, then install the OWNER RIGHTS scenario. + SetFixtureOwner(path); + parent.SetAccessControl(access); + + // Keep a no-follow handle and the original DACL alive for cleanup even + // when the regression removes access from the named fixture. + using var held = Native.CreateFileW(path, 0x00060000, 7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero); + Assert.False(held.IsInvalid); + Assert.Equal(0u, Native.GetSecurityInfo(held, 1, 5, out _, out _, out var dacl, out _, out var descriptor)); + try + { + var bytes = new byte[Native.GetSecurityDescriptorLength(descriptor)]; + Marshal.Copy(descriptor, bytes, 0, bytes.Length); + var before = new RawSecurityDescriptor(bytes, 0); + Assert.Equal(identity.User, before.Owner); + Assert.Contains("OW", before.GetSddlForm(AccessControlSections.Access)); + SetFixtureOwner(path); + using var readable = Native.CreateFileW(path, 0x00020080, 7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero); + Assert.False(readable.IsInvalid, "Owner setup removed no-follow fixture access: " + Marshal.GetLastPInvokeError()); + Assert.Equal(0u, Native.GetSecurityInfo(readable, 1, 5, out _, out _, out _, out _, out var afterDescriptor)); + try + { + var after = new byte[Native.GetSecurityDescriptorLength(afterDescriptor)]; + Marshal.Copy(afterDescriptor, after, 0, after.Length); + Assert.Equal(bytes, after); + } + finally { Native.LocalFree(afterDescriptor); } + } + finally + { + try { Assert.Equal(0u, Native.SetSecurityInfo(held, 1, 4, IntPtr.Zero, IntPtr.Zero, dacl, IntPtr.Zero)); } + finally { Native.LocalFree(descriptor); } + } + } + [System.Runtime.Versioning.SupportedOSPlatform("windows")] internal static void SetFixtureOwner(string path) { using var identity = WindowsIdentity.GetCurrent(); var sid = identity.User!; + // A redundant owner write can discard inherited OWNER RIGHTS access + // on Windows. Inspect the link/object itself and leave an already + // current-user-owned fixture's security descriptor untouched. + using (var observation = Native.CreateFileW(path, 0x00020000, 7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) + { + Assert.False(observation.IsInvalid); + Assert.Equal(0u, Native.GetSecurityInfo(observation, 1, 1, out var owner, out _, out _, out _, out var descriptor)); + try + { + if (sid.Equals(new SecurityIdentifier(owner))) return; + } + finally { Native.LocalFree(descriptor); } + } var bytes = new byte[sid.BinaryLength]; sid.GetBinaryForm(bytes, 0); using var handle = Native.CreateFileW(path, 0x00080000, 7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero); Assert.False(handle.IsInvalid); @@ -247,5 +315,8 @@ private static class Native { [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] internal static extern SafeFileHandle CreateFileW(string name, uint access, uint share, IntPtr security, uint disposition, uint flags, IntPtr template); [DllImport("advapi32.dll")] internal static extern uint SetSecurityInfo(SafeFileHandle handle, int type, uint information, IntPtr owner, IntPtr group, IntPtr dacl, IntPtr sacl); + [DllImport("advapi32.dll")] internal static extern uint GetSecurityInfo(SafeFileHandle handle, int type, uint information, out IntPtr owner, out IntPtr group, out IntPtr dacl, out IntPtr sacl, out IntPtr descriptor); + [DllImport("advapi32.dll")] internal static extern uint GetSecurityDescriptorLength(IntPtr descriptor); + [DllImport("kernel32.dll")] internal static extern IntPtr LocalFree(IntPtr memory); } } diff --git a/tools/test_once.py b/tools/test_once.py new file mode 100644 index 00000000..b98ca407 --- /dev/null +++ b/tools/test_once.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""One-off local verification; no scheduler, AI calls, or repository edits.""" +import argparse +import datetime +import json +import os +from pathlib import Path +import platform +import re +import shutil +import signal +import subprocess +import sys +import sysconfig +import tempfile +import time + + +ROOT = Path(__file__).resolve().parents[1] +PROJECTS = { + 'unit': 'tests/SkillView.Tests/SkillView.Tests.csproj', + 'integration': 'tests/SkillView.IntegrationTests/SkillView.IntegrationTests.csproj', + 'app': 'src/SkillView.App/SkillView.App.csproj', + 'extension': 'src/SkillView.GhExtension/SkillView.GhExtension.csproj', +} +OFFLINE = ('static-package-check', 'native-object-evidence', 'gitfile-protection', + 'inventory-filter', 'managed-discovery', 'quarantine-undo') +LIVE = ('revision-update', 'recorded-replacement') +LINK_PROOFS = {'static-package-check', 'gitfile-protection', 'inventory-filter', + 'quarantine-undo', 'recorded-replacement'} +RELEASE_RIDS = {'win-x64', 'win-arm64', 'linux-x64', 'osx-arm64'} + + +def make_output(requested): + base = Path('/var/tmp') if sys.platform.startswith('linux') else Path(tempfile.gettempdir()) + candidate = requested.expanduser().resolve() if requested else base.resolve() + if candidate == ROOT or ROOT in candidate.parents: + raise ValueError('Results must be outside the checkout, including through directory aliases') + # Product containment protects every .git marker, including invalid gitfiles, + # empty directories and dangling links that git rev-parse would ignore. + for ancestor in (candidate, *candidate.parents): + try: + (ancestor / '.git').lstat() + except FileNotFoundError: + continue + raise ValueError(f'Results/fixtures would be beneath a .git marker at {ancestor}; choose another --output') + if requested: + candidate.mkdir(parents=True, exist_ok=False) + return candidate + return Path(tempfile.mkdtemp(prefix='skillview-one-off-', dir=candidate)).resolve() + + +def isolated_environment(original, output): + env = {key: value for key, value in original.items() + if not key.upper().startswith(('GIT_', 'GH_', 'GITHUB_', 'SKILLVIEW_', 'CODEX_', 'CLAUDE_', 'PI_'))} + # Preserve the ordinary package cache, not the user's skill/config homes. + env['NUGET_PACKAGES'] = original.get('NUGET_PACKAGES', str(Path.home() / '.nuget' / 'packages')) + for key, leaf in { + 'HOME': 'home', 'USERPROFILE': 'home', 'DOTNET_CLI_HOME': 'dotnet-home', + 'APPDATA': 'appdata', 'LOCALAPPDATA': 'localappdata', + 'XDG_CONFIG_HOME': 'config', 'XDG_STATE_HOME': 'xdg-state', + 'XDG_DATA_HOME': 'data', 'XDG_CACHE_HOME': 'cache', + 'GH_CONFIG_DIR': 'gh-config', 'SKILLVIEW_STATE_HOME': 'state', + 'CODEX_HOME': 'home/.codex', 'CLAUDE_CONFIG_DIR': 'home/.claude', + 'PI_CODING_AGENT_DIR': 'home/.pi/agent', + 'TMPDIR': 'tmp', 'TMP': 'tmp', 'TEMP': 'tmp', + }.items(): + path = output / leaf + path.mkdir(parents=True, exist_ok=True) + env[key] = str(path) + env.update(GIT_CONFIG_COUNT='0', DOTNET_CLI_TELEMETRY_OPTOUT='1', + DOTNET_SKIP_FIRST_TIME_EXPERIENCE='1', DOTNET_NOLOGO='1', + DOTNET_CLI_UI_LANGUAGE='en-US', SKILLVIEW_EXPOSURE_LINK_PROOF='1', + GH_NO_UPDATE_NOTIFIER='1', GH_NO_EXTENSION_UPDATE_NOTIFIER='1', GH_TELEMETRY='false') + return env + + +def stop_tree(process, log): + if os.name == 'nt': + subprocess.run(['taskkill', '/PID', str(process.pid), '/T', '/F'], + stdout=log, stderr=subprocess.STDOUT, timeout=15, check=False) + else: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait(timeout=10) + + +def symlink_limitation(output): + """Probe fixture setup rights, independently of SkillView's behavior.""" + try: + with tempfile.TemporaryDirectory(prefix='symlink-probe-', dir=output) as temporary: + root = Path(temporary) + (root / 'file').write_text('fixture', encoding='utf-8') + (root / 'directory').mkdir() + (root / 'file-link').symlink_to(root / 'file') + (root / 'directory-link').symlink_to(root / 'directory', target_is_directory=True) + return None + except OSError as error: + return 'Fixture symlink creation unavailable: ' + str(error) + + +def execute(argv, *, cwd, env, log, timeout): + started = time.monotonic() + result = dict(argv=[str(value) for value in argv], log=str(log), status='failed', exitCode=None) + with log.open('w', encoding='utf-8') as stream: + try: + options = {'creationflags': subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == 'nt' else {'start_new_session': True} + process = subprocess.Popen(result['argv'], cwd=cwd, env=env, stdin=subprocess.DEVNULL, + stdout=stream, stderr=subprocess.STDOUT, **options) + try: + result['exitCode'] = process.wait(timeout=timeout) + result['status'] = 'passed' if result['exitCode'] == 0 else 'failed' + except subprocess.TimeoutExpired: + result['status'] = 'timeout' + stop_tree(process, stream) + except KeyboardInterrupt: + result['status'] = 'interrupted' + stop_tree(process, stream) + except BaseException: + stop_tree(process, stream) + raise + except (OSError, subprocess.TimeoutExpired) as error: + result['error'] = str(error) + stream.write('\n' + str(error) + '\n') + result['seconds'] = round(time.monotonic() - started, 2) + return result + + +def write_report(output, report): + (output / 'report.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + rows = ['# One-off SkillView test run', '', f"Commit: `{report.get('commit', 'unavailable')}`", + f"Platform: {report['platform']}", f"Result: **{report.get('result', 'incomplete')}**", '', + 'These are command outcomes. Read test logs for test counts and skips.', ''] + for step in report['steps']: + rows.append(f"- {step['name']}: **{step['status']}** — {Path(step['log']).name if step.get('log') else step.get('reason', '')}") + if report.get('blocked'): + rows.extend(['', 'Stopped: ' + report['blocked']]) + rows.extend(['', 'Real terminal walkthrough: not run by this script.', + 'Use docs/one-off-testing-agent.md to finish the agent-led report.']) + (output / 'REPORT.md').write_text('\n'.join(rows) + '\n', encoding='utf-8') + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--expect-commit', required=True, help='Full 40-character commit shared by all three machines') + parser.add_argument('--output', type=Path, help='New results directory outside the checkout (default: private temp directory)') + parser.add_argument('--dotnet', default='dotnet', help='Exact SDK host path if it is not on PATH') + parser.add_argument('--live', action='store_true', help='Also run actual-gh remote update/restore/undo proofs in private fixtures') + parser.add_argument('--aot', action='store_true', help='Publish both native hosts sequentially; requires an installed native toolchain') + parser.add_argument('--diagnostic-tests', action='store_true', help='Log long-running tests and MTP diagnostics for a bounded failure investigation') + args = parser.parse_args(argv) + if not re.fullmatch('[0-9a-fA-F]{40}', args.expect_commit): + parser.error('--expect-commit requires a full commit SHA') + try: + output = make_output(args.output) + except (ValueError, OSError) as error: + parser.error(str(error)) + env = isolated_environment(os.environ, output) + report = dict(platform=f'{platform.platform()} / Python build {sysconfig.get_platform()}', + startedUtc=datetime.datetime.now(datetime.timezone.utc).isoformat(), + expectedCommit=args.expect_commit.lower(), aot=args.aot, live=args.live, + diagnosticTests=args.diagnostic_tests, steps=[]) + print(f'Results: {output}', flush=True) + + def run(name, command, timeout=1200, child_env=None): + print(f'{name}: starting', flush=True) + step = execute(command, cwd=ROOT, env=child_env or env, + log=output / (name + '.log'), timeout=timeout) + step['name'] = name + report['steps'].append(step) + write_report(output, report) + print(f"{name}: {step['status']} ({step['seconds']}s)", flush=True) + if step['status'] == 'timeout': + raise RuntimeError('Stopped after a timeout; review process cleanup before another run.') + if step['status'] == 'interrupted': + raise RuntimeError('Interrupted; remaining steps were not completed.') + return step['status'] == 'passed' + + def not_run(name, reason): + report['steps'].append(dict(name=name, status='not-run', reason=reason)) + print(f'{name}: not-run ({reason})', flush=True) + + try: + if not run('git-head', ['git', 'rev-parse', 'HEAD'], 30): + raise RuntimeError('Cannot identify checkout') + report['commit'] = (output / 'git-head.log').read_text().strip() + if report['commit'] != args.expect_commit.lower(): + raise RuntimeError('Checkout does not match --expect-commit') + if not run('git-status', ['git', 'status', '--porcelain'], 30) or (output / 'git-status.log').read_text().strip(): + raise RuntimeError('Use a clean dedicated checkout; source files must match the recorded commit') + required = json.loads((ROOT / 'global.json').read_text())['sdk']['version'] + if not run('sdk-version', [args.dotnet, '--version'], 60) or (output / 'sdk-version.log').read_text().strip() != required: + raise RuntimeError(f'Install/use exact SDK {required}; keep global.json and lock files unchanged') + run('sdk-info', [args.dotnet, '--info'], 60) + gh = shutil.which('gh') + if not gh or not run('gh-version', [gh, '--version'], 30) or not run('gh-skill-help', [gh, 'skill', '--help'], 30): + raise RuntimeError('gh with the required skill surface is unavailable') + version = re.search(r'gh version (\d+)\.(\d+)\.(\d+)', (output / 'gh-version.log').read_text()) + if version is None or tuple(map(int, version.groups())) < (2, 97, 0): + raise RuntimeError('gh 2.97.0 or newer is required') + run('changes-since-release', ['git', 'log', '--oneline', 'v1.0.1..HEAD'], 30) + ready = {} + for name in ('unit', 'integration'): + project = PROJECTS[name] + ready[name] = run(name + '-restore', [args.dotnet, 'restore', project, '--locked-mode']) + if ready[name]: + ready[name] = run(name + '-build', [args.dotnet, 'build', project, '-c', 'Release', '--no-restore']) + if ready[name]: + diagnostic = ['--long-running', '60', '--xunit-diagnostics', 'on', '--progress', 'on', + '--diagnostic', '--diagnostic-output-directory', str(output / (name + '-diagnostics'))] if args.diagnostic_tests else [] + run(name + '-tests', [args.dotnet, 'test', '--project', project, '-c', 'Release', '--no-build', '--no-restore', *diagnostic]) + else: + not_run(name + '-tests', 'Restore/build failed') + rid_match = re.search(r'^\s*RID:\s*(\S+)', (output / 'sdk-info.log').read_text(), re.MULTILINE) + rid = rid_match.group(1) if rid_match else None + if args.aot and rid not in RELEASE_RIDS: + raise RuntimeError(f'No committed AOT release RID for {rid}; run managed mode and report AOT coverage missing') + hosts = {} + suffix = '.exe' if os.name == 'nt' else '' + for name in ('app', 'extension'): + project = PROJECTS[name] + flags = ['-r', rid] if args.aot else ['-p:SelfContained=false', '-p:UseDefaultPublishRuntimeIdentifier=false'] + destination = output / name if args.aot else ROOT / Path(project).parent / 'bin/Release/net10.0' + binary = destination / (('skillview' if name == 'app' else 'gh-skillview') + suffix) + if args.aot: + # Match CI: publish restores with RuntimeIdentifier while keeping + # the four-RID RuntimeIdentifiers set in the committed locks. + # `dotnet restore -r` overrides that set and causes NU1004. + restored = True + command = [args.dotnet, 'publish', project, '-c', 'Release', *flags, + '-p:PublishAot=true', '-p:StripSymbols=true', '-p:RestoreLockedMode=true', + '-o', str(destination)] + if name == 'app': + command.extend(['-warnaserror:IL2026', '-warnaserror:IL3050', '-warnaserror:IL3053']) + else: + restored = run(name + '-restore', [args.dotnet, 'restore', project, '--locked-mode', *flags]) + command = [args.dotnet, 'build', project, '-c', 'Release', '--no-restore', *flags] + if restored and run(name + '-build', command) and run(name + '-version', [str(binary), '--version'], 60): + hosts[name] = binary + else: + not_run(name + '-proofs', 'Product restore/build failed') + report['hosts'] = {name: str(binary) for name, binary in hosts.items()} + link_reason = symlink_limitation(output) if os.name == 'nt' else None + for name, binary in hosts.items(): + for proof in OFFLINE: + if link_reason and proof in LINK_PROOFS: + not_run(name + '-' + proof, link_reason) + continue + command = [sys.executable, str(ROOT / 'agent_docs/evidence' / ('prove-' + proof + '.py')), + '--app', str(binary), '--output', str(output / f'{name}-{proof}.json')] + if proof != 'static-package-check': + command.extend(['--gh', gh]) + run(name + '-' + proof, command) + if args.live: + token = os.environ.get('GH_TOKEN') or os.environ.get('GITHUB_TOKEN') + if not token: + # Credentials remain in memory and child environments, never command logs. + auth = subprocess.run([gh, 'auth', 'token', '--hostname', 'github.com'], cwd=ROOT, + capture_output=True, text=True, timeout=30, check=False) + token = auth.stdout.strip() if auth.returncode == 0 else None + if token: + live_env = dict(env, GH_TOKEN=token) + for name, binary in hosts.items(): + for proof in LIVE: + if link_reason and proof in LINK_PROOFS: + not_run(name + '-' + proof, link_reason) + continue + run(name + '-' + proof, [sys.executable, str(ROOT / 'agent_docs/evidence' / ('prove-' + proof + '.py')), + '--app', str(binary), '--gh', gh, '--output', str(output / f'{name}-{proof}.json')], child_env=live_env) + else: + not_run('live-proofs', 'No existing gh authentication; offline checks still ran') + else: + not_run('live-proofs', 'Not requested; rerun with --live for remote install/update/undo coverage') + if not run('source-status-after', ['git', 'status', '--porcelain'], 30) or (output / 'source-status-after.log').read_text().strip(): + raise RuntimeError('Checkout changed during testing; inspect before interpreting results') + except (RuntimeError, OSError, subprocess.TimeoutExpired, KeyboardInterrupt) as error: + report['blocked'] = str(error) or 'Interrupted' + finally: + failed = any(step['status'] in ('failed', 'timeout') for step in report['steps']) + partial = any(step['status'] == 'not-run' for step in report['steps']) + report['result'] = 'failed' if failed else 'blocked' if report.get('blocked') else 'partial' if partial else 'passed' + write_report(output, report) + print(f"Result: {report['result']}; report: {output / 'REPORT.md'}", flush=True) + return 1 if failed else 2 if report['result'] != 'passed' else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/tests/test_test_once.py b/tools/tests/test_test_once.py new file mode 100644 index 00000000..e113e529 --- /dev/null +++ b/tools/tests/test_test_once.py @@ -0,0 +1,182 @@ +"""Portable smoke contracts for the one-off runner, not product verification.""" +import importlib.util +import contextlib +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + + +SPEC = importlib.util.spec_from_file_location('test_once', Path(__file__).resolve().parents[1] / 'test_once.py') +runner = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(runner) +TEMP_BASE = '/var/tmp' if sys.platform.startswith('linux') else None + + +class TestOnceTests(unittest.TestCase): + def test_environment_is_private_and_parent_is_unchanged(self): + original = dict(os.environ, GH_TOKEN='secret', GIT_DIR='foreign', CODEX_HOME='foreign', + SKILLVIEW_CONTRACT_TESTS='true', CLAUDE_CODE_PLUGIN_DIRS='foreign') + before = dict(original) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + env = runner.isolated_environment(original, root) + self.assertNotIn('GH_TOKEN', env) + self.assertNotIn('GIT_DIR', env) + self.assertNotIn('SKILLVIEW_CONTRACT_TESTS', env) + self.assertNotIn('CLAUDE_CODE_PLUGIN_DIRS', env) + for key in ('HOME', 'USERPROFILE', 'TMPDIR', 'SKILLVIEW_STATE_HOME', 'CODEX_HOME'): + self.assertTrue(Path(env[key]).is_relative_to(root)) + self.assertEqual('1', env['SKILLVIEW_EXPOSURE_LINK_PROOF']) + self.assertEqual(before, original) + + def test_results_cannot_write_through_an_alias_into_the_checkout(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / 'repo' + root.mkdir() + alias = Path(temporary) / 'alias' + try: + alias.symlink_to(root, target_is_directory=True) + except OSError as error: + if getattr(error, 'winerror', None) == 1314: + self.skipTest('Windows token cannot create the directory-alias fixture') + raise + with patch.object(runner, 'ROOT', root.resolve()): + with self.assertRaisesRegex(ValueError, 'outside the checkout'): + runner.make_output(alias / 'results') + self.assertFalse((root / 'results').exists()) + + def test_results_reject_even_invalid_ancestor_git_markers_before_creating_output(self): + for kind in ('directory', 'file', 'broken-link'): + with self.subTest(kind=kind), tempfile.TemporaryDirectory(dir=TEMP_BASE) as temporary: + root = Path(temporary) + marker = root / '.git' + if kind == 'directory': + marker.mkdir() + elif kind == 'file': + marker.write_text('not a valid gitfile', encoding='utf-8') + else: + try: + marker.symlink_to(root / 'missing') + except OSError as error: + if getattr(error, 'winerror', None) == 1314: + self.skipTest('Windows token cannot create the broken-marker fixture') + raise + output = root / 'nested' / 'results' + with self.assertRaisesRegex(ValueError, 'beneath a .git marker'): + runner.make_output(output) + self.assertFalse(output.parent.exists()) + + def test_results_accept_a_new_directory_outside_repository_markers(self): + with tempfile.TemporaryDirectory(dir=TEMP_BASE) as temporary: + output = Path(temporary) / 'results' + self.assertEqual(output.resolve(), runner.make_output(output)) + self.assertTrue(output.is_dir()) + + def test_command_failure_keeps_exit_status_and_log(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + result = runner.execute([sys.executable, '-c', 'print("diagnostic"); raise SystemExit(9)'], + cwd=root, env=os.environ.copy(), log=root / 'command.log', timeout=30) + self.assertEqual('failed', result['status']) + self.assertEqual(9, result['exitCode']) + self.assertIn('diagnostic', (root / 'command.log').read_text()) + + def test_fixture_symlink_refusal_is_an_explicit_limitation(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + with patch.object(Path, 'symlink_to', side_effect=OSError('fixture permission denied')): + reason = runner.symlink_limitation(root) + self.assertIn('fixture permission denied', reason) + self.assertEqual([], list(root.iterdir())) + self.assertIn('recorded-replacement', runner.LINK_PROOFS) + self.assertNotIn('revision-update', runner.LINK_PROOFS) + + def test_interruption_keeps_a_step_result_after_owned_tree_cleanup(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + with patch.object(runner.subprocess, 'Popen') as start, patch.object(runner, 'stop_tree') as stop: + start.return_value.wait.side_effect = KeyboardInterrupt + result = runner.execute(['fixture-command'], cwd=root, env={}, log=root / 'interrupt.log', timeout=30) + stop.assert_called_once() + self.assertEqual('interrupted', result['status']) + self.assertEqual(['fixture-command'], result['argv']) + + def test_aot_run_uses_locked_ci_publish_instead_of_single_rid_restore(self): + with tempfile.TemporaryDirectory(dir=TEMP_BASE) as temporary: + root = Path(temporary) + repo = root / 'repo' + repo.mkdir() + (repo / 'global.json').write_text(json.dumps({'sdk': {'version': '10.0.402'}})) + commands = [] + + def fake_execute(argv, *, log, **unused): + commands.append(argv) + contents = {'git-head': 'a' * 40, 'sdk-version': '10.0.402', + 'sdk-info': 'RID: win-arm64', 'gh-version': 'gh version 2.102.0'} + log.write_text(contents.get(log.stem, ''), encoding='utf-8') + return dict(argv=argv, log=str(log), status='passed', exitCode=0, seconds=0) + + with patch.object(runner, 'ROOT', repo), patch.object(runner, 'execute', side_effect=fake_execute), \ + patch.object(runner.shutil, 'which', return_value='fixture-gh'), contextlib.redirect_stdout(io.StringIO()): + code = runner.main(['--expect-commit', 'a' * 40, '--output', str(root / 'results'), '--aot', '--diagnostic-tests']) + self.assertEqual(2, code) # Live proofs were deliberately not requested. + restores = [command for command in commands if command[1] == 'restore'] + self.assertEqual([runner.PROJECTS['unit'], runner.PROJECTS['integration']], [command[2] for command in restores]) + publishes = [command for command in commands if command[1] == 'publish'] + self.assertEqual([runner.PROJECTS['app'], runner.PROJECTS['extension']], [command[2] for command in publishes]) + for command in publishes: + self.assertIn('-p:RestoreLockedMode=true', command) + self.assertNotIn('--no-restore', command) + self.assertEqual('win-arm64', command[command.index('-r') + 1]) + self.assertIn('-warnaserror:IL3050', publishes[0]) + tests = [command for command in commands if command[1] == 'test'] + self.assertEqual(2, len(tests)) + for command in tests: + self.assertIn('--long-running', command) + self.assertIn('--diagnostic', command) + self.assertNotIn('--', command) # MTP flags are forwarded directly. + + def test_timeout_stops_the_owned_descendant_before_it_writes(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + sentinel = root / 'must-not-appear' + child = 'import time; from pathlib import Path; time.sleep(2.5); Path(' + repr(str(sentinel)) + ').write_text("late")' + parent = ('import subprocess,sys,time; subprocess.Popen([sys.executable,"-c",' + repr(child) + + ']); print("ready",flush=True); time.sleep(30)') + result = runner.execute([sys.executable, '-c', parent], cwd=root, env=os.environ.copy(), + log=root / 'timeout.log', timeout=1) + self.assertEqual('timeout', result['status']) + self.assertIn('ready', (root / 'timeout.log').read_text()) + time.sleep(1.8) + self.assertFalse(sentinel.exists()) + + def test_wrong_commit_blocks_before_sdk_or_product_commands(self): + with tempfile.TemporaryDirectory(dir=TEMP_BASE) as temporary: + root = Path(temporary) / 'repo' + root.mkdir() + env = {key: value for key, value in os.environ.items() if not key.upper().startswith('GIT_')} + env.update(GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL=os.devnull) + subprocess.run(['git', 'init', '--quiet', str(root)], check=True, env=env) + (root / 'README.md').write_text('fixture\n') + subprocess.run(['git', '-C', str(root), 'add', '.'], check=True, env=env) + subprocess.run(['git', '-C', str(root), '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', + '-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', 'fixture'], check=True, env=env) + output = Path(temporary) / 'results' + with patch.object(runner, 'ROOT', root): + code = runner.main(['--expect-commit', '0' * 40, '--output', str(output)]) + report = json.loads((output / 'report.json').read_text()) + self.assertEqual(2, code) + self.assertEqual('blocked', report['result']) + self.assertEqual(['git-head'], [step['name'] for step in report['steps']]) + self.assertIn('does not match', report['blocked']) + + +if __name__ == '__main__': + unittest.main()