diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 592d4f9b..f6ea26e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,6 +95,17 @@ jobs: ENDSTATE_ENGINE_PATH: ${{ github.workspace }}/endstate-linux-amd64 run: npm run test:contract + - name: Assert profile inspection golden is committed + shell: bash + run: | + if ! git diff --quiet -- tests/fixtures/profile-inspect-envelope.golden.json; then + echo "::error file=tests/fixtures/profile-inspect-envelope.golden.json::The real engine's profile inspection envelope no longer matches the committed golden fixture." + git --no-pager diff -- tests/fixtures/profile-inspect-envelope.golden.json + echo "Regenerate locally with: ENDSTATE_ENGINE_PATH= npm run test:contract" + exit 1 + fi + echo "Profile inspection golden matches the pinned engine." + # A genuine install against a real package manager. Every other job either # mocks the engine or only exercises its error paths, so nothing else proves # that apply actually installs, that a dry run installs nothing, or that a diff --git a/e2e/fixtures/README.md b/e2e/fixtures/README.md index 9ca44c5d..dabd1bab 100644 --- a/e2e/fixtures/README.md +++ b/e2e/fixtures/README.md @@ -4,6 +4,19 @@ The `tauri.ts` fixture provides context-level `__TAURI__` and `__ENDSTATE_MOCK_ENGINE__` mocks for E2E tests that require these APIs to be available **before** page creation. +## Profile inspection fixture + +`profile_inspect.fixture.json` is the deterministic long-profile response for +the **What's inside** E2E flow. It is deliberately an engine-shaped successful +`profile inspect --json` envelope: 72 Apps rows, eight verified settings rows +(seven included and one app not in the profile), and an inventory-incomplete +warning. The mock-engine conformance test keeps its required keys, nullability, +status matrix, summary semantics, and representative rows aligned with +`tests/fixtures/profile-inspect-envelope.golden.json` from the real engine. + +Package references and module IDs are included so search can be tested without +showing those technical values in the normal row UI. + ## When to Use the Fixture Use `import { test, expect } from './fixtures/tauri'` when: diff --git a/e2e/fixtures/profile_inspect.fixture.json b/e2e/fixtures/profile_inspect.fixture.json new file mode 100644 index 00000000..ef698ea6 --- /dev/null +++ b/e2e/fixtures/profile_inspect.fixture.json @@ -0,0 +1,827 @@ +{ + "schemaVersion": "1.0", + "cliVersion": "2.30.0", + "command": "profile", + "runId": "profile-inspect-e2e-mock", + "timestampUtc": "2026-08-01T00:00:00.000Z", + "success": true, + "data": { + "profile": { + "name": "hugo-desktop", + "capturedAt": "2026-02-28T16:55:07Z", + "manifestVersion": 2, + "manifestPath": "C:\\test\\profiles\\hugo-desktop.jsonc" + }, + "apps": [ + { + "id": "app:7zip:1", + "manifestAppId": "7zip", + "displayName": "7-Zip", + "packageRefs": [ + "Vendor.7zip" + ], + "hasSettings": true + }, + { + "id": "app:adobe-creative-cloud:1", + "manifestAppId": "adobe-creative-cloud", + "displayName": "Adobe Creative Cloud", + "packageRefs": [ + "Vendor.AdobeCreativeCloud" + ], + "hasSettings": true + }, + { + "id": "app:apple-mobile-device-support:1", + "manifestAppId": "apple-mobile-device-support", + "displayName": "Apple Mobile Device Support", + "packageRefs": [ + "Vendor.AppleMobileDeviceSupport" + ], + "hasSettings": true + }, + { + "id": "app:apple-software-update:1", + "manifestAppId": "apple-software-update", + "displayName": "Apple Software Update", + "packageRefs": [ + "Vendor.AppleSoftwareUpdate" + ], + "hasSettings": true + }, + { + "id": "app:audacity:1", + "manifestAppId": "audacity", + "displayName": "Audacity", + "packageRefs": [ + "Vendor.Audacity" + ], + "hasSettings": false + }, + { + "id": "app:bitwarden:1", + "manifestAppId": "bitwarden", + "displayName": "Bitwarden", + "packageRefs": [ + "Vendor.Bitwarden" + ], + "hasSettings": true + }, + { + "id": "app:blender:1", + "manifestAppId": "blender", + "displayName": "Blender", + "packageRefs": [ + "Vendor.Blender" + ], + "hasSettings": false + }, + { + "id": "app:brave:1", + "manifestAppId": "brave", + "displayName": "Brave", + "packageRefs": [ + "Vendor.Brave" + ], + "hasSettings": true + }, + { + "id": "app:calibre:1", + "manifestAppId": "calibre", + "displayName": "Calibre", + "packageRefs": [ + "Vendor.Calibre" + ], + "hasSettings": false + }, + { + "id": "app:capcut:1", + "manifestAppId": "capcut", + "displayName": "CapCut", + "packageRefs": [ + "Vendor.Capcut" + ], + "hasSettings": false + }, + { + "id": "app:cloudflared:1", + "manifestAppId": "cloudflared", + "displayName": "cloudflared", + "packageRefs": [ + "Vendor.Cloudflared" + ], + "hasSettings": false + }, + { + "id": "app:cursor:1", + "manifestAppId": "cursor", + "displayName": "Cursor", + "packageRefs": [ + "AnySphere.Cursor", + "com.endstate.hidden-package-ref" + ], + "hasSettings": true + }, + { + "id": "app:cygwin:1", + "manifestAppId": "cygwin", + "displayName": "Cygwin", + "packageRefs": [ + "Vendor.Cygwin" + ], + "hasSettings": false + }, + { + "id": "app:dbeaver:1", + "manifestAppId": "dbeaver", + "displayName": "DBeaver", + "packageRefs": [ + "Vendor.Dbeaver" + ], + "hasSettings": false + }, + { + "id": "app:discord:1", + "manifestAppId": "discord", + "displayName": "Discord", + "packageRefs": [ + "Vendor.Discord" + ], + "hasSettings": false + }, + { + "id": "app:docker-desktop:1", + "manifestAppId": "docker-desktop", + "displayName": "Docker Desktop", + "packageRefs": [ + "Vendor.DockerDesktop" + ], + "hasSettings": false + }, + { + "id": "app:ffmpeg:1", + "manifestAppId": "ffmpeg", + "displayName": "FFmpeg", + "packageRefs": [ + "Vendor.Ffmpeg" + ], + "hasSettings": false + }, + { + "id": "app:figma:1", + "manifestAppId": "figma", + "displayName": "Figma", + "packageRefs": [ + "Vendor.Figma" + ], + "hasSettings": false + }, + { + "id": "app:firefox:1", + "manifestAppId": "firefox", + "displayName": "Firefox", + "packageRefs": [ + "Vendor.Firefox" + ], + "hasSettings": false + }, + { + "id": "app:gimp:1", + "manifestAppId": "gimp", + "displayName": "GIMP", + "packageRefs": [ + "Vendor.Gimp" + ], + "hasSettings": false + }, + { + "id": "app:git:1", + "manifestAppId": "git", + "displayName": "Git", + "packageRefs": [ + "Vendor.Git" + ], + "hasSettings": false + }, + { + "id": "app:github-desktop:1", + "manifestAppId": "github-desktop", + "displayName": "GitHub Desktop", + "packageRefs": [ + "Vendor.GithubDesktop" + ], + "hasSettings": false + }, + { + "id": "app:go:1", + "manifestAppId": "go", + "displayName": "Go", + "packageRefs": [ + "Vendor.Go" + ], + "hasSettings": false + }, + { + "id": "app:google-chrome:1", + "manifestAppId": "google-chrome", + "displayName": "Google Chrome", + "packageRefs": [ + "Vendor.GoogleChrome" + ], + "hasSettings": false + }, + { + "id": "app:handbrake:1", + "manifestAppId": "handbrake", + "displayName": "HandBrake", + "packageRefs": [ + "Vendor.Handbrake" + ], + "hasSettings": false + }, + { + "id": "app:inkscape:1", + "manifestAppId": "inkscape", + "displayName": "Inkscape", + "packageRefs": [ + "Vendor.Inkscape" + ], + "hasSettings": false + }, + { + "id": "app:kdenlive:1", + "manifestAppId": "kdenlive", + "displayName": "Kdenlive", + "packageRefs": [ + "Vendor.Kdenlive" + ], + "hasSettings": false + }, + { + "id": "app:keepassxc:1", + "manifestAppId": "keepassxc", + "displayName": "KeePassXC", + "packageRefs": [ + "Vendor.Keepassxc" + ], + "hasSettings": false + }, + { + "id": "app:libreoffice:1", + "manifestAppId": "libreoffice", + "displayName": "LibreOffice", + "packageRefs": [ + "Vendor.Libreoffice" + ], + "hasSettings": false + }, + { + "id": "app:lm-studio:1", + "manifestAppId": "lm-studio", + "displayName": "LM Studio", + "packageRefs": [ + "Vendor.LmStudio" + ], + "hasSettings": false + }, + { + "id": "app:microsoft-powertoys:1", + "manifestAppId": "microsoft-powertoys", + "displayName": "Microsoft PowerToys", + "packageRefs": [ + "Vendor.MicrosoftPowertoys" + ], + "hasSettings": false + }, + { + "id": "app:microsoft-teams:1", + "manifestAppId": "microsoft-teams", + "displayName": "Microsoft Teams", + "packageRefs": [ + "Vendor.MicrosoftTeams" + ], + "hasSettings": false + }, + { + "id": "app:mongodb-compass:1", + "manifestAppId": "mongodb-compass", + "displayName": "MongoDB Compass", + "packageRefs": [ + "Vendor.MongodbCompass" + ], + "hasSettings": false + }, + { + "id": "app:neovim:1", + "manifestAppId": "neovim", + "displayName": "Neovim", + "packageRefs": [ + "Vendor.Neovim" + ], + "hasSettings": false + }, + { + "id": "app:nodejs:1", + "manifestAppId": "nodejs", + "displayName": "Node.js", + "packageRefs": [ + "Vendor.Nodejs" + ], + "hasSettings": false + }, + { + "id": "app:notion:1", + "manifestAppId": "notion", + "displayName": "Notion", + "packageRefs": [ + "Vendor.Notion" + ], + "hasSettings": false + }, + { + "id": "app:nvidia-app:1", + "manifestAppId": "nvidia-app", + "displayName": "NVIDIA App", + "packageRefs": [ + "Vendor.NvidiaApp" + ], + "hasSettings": false + }, + { + "id": "app:obsidian:1", + "manifestAppId": "obsidian", + "displayName": "Obsidian", + "packageRefs": [ + "Vendor.Obsidian" + ], + "hasSettings": false + }, + { + "id": "app:ollama:1", + "manifestAppId": "ollama", + "displayName": "Ollama", + "packageRefs": [ + "Vendor.Ollama" + ], + "hasSettings": false + }, + { + "id": "app:jdk:1", + "manifestAppId": "jdk", + "displayName": "OpenJDK", + "packageRefs": [ + "Vendor.Jdk" + ], + "hasSettings": false + }, + { + "id": "app:openvpn:1", + "manifestAppId": "openvpn", + "displayName": "OpenVPN", + "packageRefs": [ + "Vendor.Openvpn" + ], + "hasSettings": false + }, + { + "id": "app:paint-net:1", + "manifestAppId": "paint-net", + "displayName": "Paint.NET", + "packageRefs": [ + "Vendor.PaintNet" + ], + "hasSettings": false + }, + { + "id": "app:postgresql:1", + "manifestAppId": "postgresql", + "displayName": "PostgreSQL", + "packageRefs": [ + "Vendor.Postgresql" + ], + "hasSettings": false + }, + { + "id": "app:postman:1", + "manifestAppId": "postman", + "displayName": "Postman", + "packageRefs": [ + "Vendor.Postman" + ], + "hasSettings": false + }, + { + "id": "app:powershell:1", + "manifestAppId": "powershell", + "displayName": "PowerShell", + "packageRefs": [ + "Vendor.Powershell" + ], + "hasSettings": false + }, + { + "id": "app:putty:1", + "manifestAppId": "putty", + "displayName": "PuTTY", + "packageRefs": [ + "Vendor.Putty" + ], + "hasSettings": false + }, + { + "id": "app:python:1", + "manifestAppId": "python", + "displayName": "Python", + "packageRefs": [ + "Vendor.Python" + ], + "hasSettings": false + }, + { + "id": "app:qbittorrent:1", + "manifestAppId": "qbittorrent", + "displayName": "qBittorrent", + "packageRefs": [ + "Vendor.Qbittorrent" + ], + "hasSettings": false + }, + { + "id": "app:burntsushi-ripgrep-msvc:1", + "manifestAppId": "burntsushi-ripgrep-msvc", + "displayName": "ripgrep (MSVC)", + "packageRefs": [ + "Vendor.BurntsushiRipgrepMsvc" + ], + "hasSettings": false + }, + { + "id": "app:rustup:1", + "manifestAppId": "rustup", + "displayName": "Rustup", + "packageRefs": [ + "Vendor.Rustup" + ], + "hasSettings": false + }, + { + "id": "app:signal:1", + "manifestAppId": "signal", + "displayName": "Signal", + "packageRefs": [ + "Vendor.Signal" + ], + "hasSettings": false + }, + { + "id": "app:slack:1", + "manifestAppId": "slack", + "displayName": "Slack", + "packageRefs": [ + "Vendor.Slack" + ], + "hasSettings": false + }, + { + "id": "app:spotify:1", + "manifestAppId": "spotify", + "displayName": "Spotify", + "packageRefs": [ + "Vendor.Spotify" + ], + "hasSettings": false + }, + { + "id": "app:steam:1", + "manifestAppId": "steam", + "displayName": "Steam", + "packageRefs": [ + "Vendor.Steam" + ], + "hasSettings": false + }, + { + "id": "app:sysinternals:1", + "manifestAppId": "sysinternals", + "displayName": "Sysinternals", + "packageRefs": [ + "Vendor.Sysinternals" + ], + "hasSettings": false + }, + { + "id": "app:telegram:1", + "manifestAppId": "telegram", + "displayName": "Telegram Desktop", + "packageRefs": [ + "Vendor.Telegram" + ], + "hasSettings": false + }, + { + "id": "app:termius:1", + "manifestAppId": "termius", + "displayName": "Termius", + "packageRefs": [ + "Vendor.Termius" + ], + "hasSettings": false + }, + { + "id": "app:thunderbird:1", + "manifestAppId": "thunderbird", + "displayName": "Thunderbird", + "packageRefs": [ + "Vendor.Thunderbird" + ], + "hasSettings": false + }, + { + "id": "app:transmission:1", + "manifestAppId": "transmission", + "displayName": "Transmission", + "packageRefs": [ + "Vendor.Transmission" + ], + "hasSettings": false + }, + { + "id": "app:unified-remote:1", + "manifestAppId": "unified-remote", + "displayName": "Unified Remote", + "packageRefs": [ + "Vendor.UnifiedRemote" + ], + "hasSettings": false + }, + { + "id": "app:vcredist:1", + "manifestAppId": "vcredist", + "displayName": "Visual C++ Redistributable", + "packageRefs": [ + "Vendor.Vcredist" + ], + "hasSettings": false + }, + { + "id": "app:visual-studio:1", + "manifestAppId": "visual-studio", + "displayName": "Visual Studio", + "packageRefs": [ + "Vendor.VisualStudio" + ], + "hasSettings": false + }, + { + "id": "app:vscode:1", + "manifestAppId": "vscode", + "displayName": "Visual Studio Code", + "packageRefs": [ + "Vendor.Vscode" + ], + "hasSettings": false + }, + { + "id": "app:vlc:1", + "manifestAppId": "vlc", + "displayName": "VLC media player", + "packageRefs": [ + "Vendor.Vlc" + ], + "hasSettings": false + }, + { + "id": "app:warp:1", + "manifestAppId": "warp", + "displayName": "Warp", + "packageRefs": [ + "Vendor.Warp" + ], + "hasSettings": false + }, + { + "id": "app:windows-terminal:1", + "manifestAppId": "windows-terminal", + "displayName": "Windows Terminal", + "packageRefs": [ + "Vendor.WindowsTerminal" + ], + "hasSettings": false + }, + { + "id": "app:winfsp:1", + "manifestAppId": "winfsp", + "displayName": "WinFsp", + "packageRefs": [ + "Vendor.Winfsp" + ], + "hasSettings": false + }, + { + "id": "app:winscp:1", + "manifestAppId": "winscp", + "displayName": "WinSCP", + "packageRefs": [ + "Vendor.Winscp" + ], + "hasSettings": false + }, + { + "id": "app:xconvert:1", + "manifestAppId": "xconvert", + "displayName": "XnConvert", + "packageRefs": [ + "Vendor.Xconvert" + ], + "hasSettings": false + }, + { + "id": "app:yt-dlp:1", + "manifestAppId": "yt-dlp", + "displayName": "yt-dlp", + "packageRefs": [ + "Vendor.YtDlp" + ], + "hasSettings": false + }, + { + "id": "app:yt-dlp-ffmpeg:1", + "manifestAppId": "yt-dlp-ffmpeg", + "displayName": "yt-dlp FFmpeg", + "packageRefs": [ + "Vendor.YtDlpFfmpeg" + ], + "hasSettings": false + }, + { + "id": "app:zoom:1", + "manifestAppId": "zoom", + "displayName": "Zoom", + "packageRefs": [ + "Vendor.Zoom" + ], + "hasSettings": false + } + ], + "settingsApps": [ + { + "id": "settings:app:7zip:1", + "displayName": "7-Zip settings", + "associationStatus": "included", + "ownerId": "app:7zip:1", + "appId": "app:7zip:1", + "appIncluded": true, + "packageRefs": [ + "Vendor.7zip" + ], + "moduleIds": [ + "apps.7zip" + ], + "candidateAppIds": [ + "app:7zip:1" + ], + "capturedEntryCount": 0 + }, + { + "id": "settings:package:vendor.absent", + "displayName": "Absent settings", + "associationStatus": "not_in_profile", + "ownerId": "package:vendor.absent", + "appId": null, + "appIncluded": false, + "packageRefs": [ + "Vendor.Absent" + ], + "moduleIds": [ + "apps.absent" + ], + "candidateAppIds": [], + "capturedEntryCount": 0 + }, + { + "id": "settings:app:adobe-creative-cloud:1", + "displayName": "Adobe Creative Cloud settings", + "associationStatus": "included", + "ownerId": "app:adobe-creative-cloud:1", + "appId": "app:adobe-creative-cloud:1", + "appIncluded": true, + "packageRefs": [ + "Vendor.AdobeCreativeCloud" + ], + "moduleIds": [ + "apps.adobe-creative-cloud" + ], + "candidateAppIds": [ + "app:adobe-creative-cloud:1" + ], + "capturedEntryCount": 0 + }, + { + "id": "settings:app:apple-mobile-device-support:1", + "displayName": "Apple Mobile Device Support settings", + "associationStatus": "included", + "ownerId": "app:apple-mobile-device-support:1", + "appId": "app:apple-mobile-device-support:1", + "appIncluded": true, + "packageRefs": [ + "Vendor.AppleMobileDeviceSupport" + ], + "moduleIds": [ + "apps.apple-mobile-device-support" + ], + "candidateAppIds": [ + "app:apple-mobile-device-support:1" + ], + "capturedEntryCount": 0 + }, + { + "id": "settings:app:apple-software-update:1", + "displayName": "Apple Software Update settings", + "associationStatus": "included", + "ownerId": "app:apple-software-update:1", + "appId": "app:apple-software-update:1", + "appIncluded": true, + "packageRefs": [ + "Vendor.AppleSoftwareUpdate" + ], + "moduleIds": [ + "apps.apple-software-update" + ], + "candidateAppIds": [ + "app:apple-software-update:1" + ], + "capturedEntryCount": 0 + }, + { + "id": "settings:app:bitwarden:1", + "displayName": "Bitwarden settings", + "associationStatus": "included", + "ownerId": "app:bitwarden:1", + "appId": "app:bitwarden:1", + "appIncluded": true, + "packageRefs": [ + "Vendor.Bitwarden" + ], + "moduleIds": [ + "apps.bitwarden" + ], + "candidateAppIds": [ + "app:bitwarden:1" + ], + "capturedEntryCount": 0 + }, + { + "id": "settings:app:brave:1", + "displayName": "Brave settings", + "associationStatus": "included", + "ownerId": "app:brave:1", + "appId": "app:brave:1", + "appIncluded": true, + "packageRefs": [ + "Vendor.Brave" + ], + "moduleIds": [ + "apps.brave" + ], + "candidateAppIds": [ + "app:brave:1" + ], + "capturedEntryCount": 0 + }, + { + "id": "settings:app:cursor:1", + "displayName": "Cursor settings", + "associationStatus": "included", + "ownerId": "app:cursor:1", + "appId": "app:cursor:1", + "appIncluded": true, + "packageRefs": [ + "AnySphere.Cursor", + "com.endstate.hidden-package-ref" + ], + "moduleIds": [ + "apps.cursor", + "apps.hidden-module-id" + ], + "candidateAppIds": [ + "app:cursor:1" + ], + "capturedEntryCount": 0 + } + ], + "warnings": [ + { + "code": "SETTINGS_INVENTORY_INCOMPLETE", + "message": "Some settings inventory could not be verified.", + "impact": "inventory_incomplete" + } + ], + "summary": { + "appCount": 72, + "settingsRowCount": 8, + "verifiedSettingsAppCount": 8, + "unidentifiedSettingsRowCount": 0 + } + }, + "error": null +} diff --git a/e2e/profile-contents.spec.ts b/e2e/profile-contents.spec.ts new file mode 100644 index 00000000..72d0ba7e --- /dev/null +++ b/e2e/profile-contents.spec.ts @@ -0,0 +1,97 @@ +import { test, expect } from './fixtures/tauri'; +import { goToApplyPage } from './helpers/ui-mode'; + +const PROFILE_PATH = 'C:\\test\\profiles\\hugo-desktop.jsonc'; + +test.describe("profile contents inspection", () => { + test.use({ + tauriMockOptions: { + initialProfileFiles: [PROFILE_PATH], + }, + }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + // Let the app install its committed scenario-driven mock instead of the + // fixture's generic boot mock, then select the inspection scenario. + (window as any).__ENDSTATE_MOCK_ENGINE__ = undefined; + (window as any).__ENDSTATE_E2E_SCENARIO__ = 'profile_inspect_ok'; + (window as any).__ENDSTATE_E2E_COMMANDS__ = []; + }); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await goToApplyPage(page); + }); + + test('inspects a discovered profile without selecting or previewing it', async ({ page }) => { + const card = page.getByTestId('profile-card-hugo-desktop'); + await expect(card).toBeVisible(); + await expect(card.getByRole('button', { name: 'Select' })).toBeVisible(); + + await card.getByRole('button', { name: "What's inside hugo-desktop" }).click(); + + const dialog = page.getByRole('dialog', { name: "What's inside" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText('72 apps', { exact: true })).toBeVisible(); + await expect(dialog.getByText('Settings for 8 apps', { exact: true })).toBeVisible(); + + const appsTab = dialog.getByRole('tab', { name: 'Apps (72)' }); + const settingsTab = dialog.getByRole('tab', { name: 'App settings (8)' }); + await expect(appsTab).toHaveAttribute('aria-selected', 'true'); + + await appsTab.press('ArrowRight'); + await expect(settingsTab).toHaveAttribute('aria-selected', 'true'); + await expect(settingsTab).toBeFocused(); + await settingsTab.press('Home'); + await expect(appsTab).toHaveAttribute('aria-selected', 'true'); + await appsTab.press('End'); + await expect(settingsTab).toHaveAttribute('aria-selected', 'true'); + + await appsTab.click(); + const appsSearch = dialog.getByRole('searchbox', { name: 'Search apps' }); + await appsSearch.fill('hidden-package-ref'); + await expect(dialog.getByText('Cursor', { exact: true })).toBeVisible(); + const appsPanel = dialog.getByRole('tabpanel', { includeHidden: true }).nth(0); + await expect(appsPanel.getByRole('listitem')).toHaveCount(1); + await expect(dialog.getByText('72 apps', { exact: true })).toBeVisible(); + + await settingsTab.click(); + const settingsSearch = dialog.getByRole('searchbox', { name: 'Search app settings' }); + await settingsSearch.fill('hidden-module-id'); + await expect(dialog.getByText('Cursor settings', { exact: true })).toBeVisible(); + const settingsPanel = dialog.getByRole('tabpanel', { includeHidden: true }).nth(1); + await expect(settingsPanel.getByRole('listitem')).toHaveCount(1); + + await appsTab.click(); + await expect(appsSearch).toHaveValue('hidden-package-ref'); + await settingsTab.click(); + await expect(settingsSearch).toHaveValue('hidden-module-id'); + await settingsSearch.fill(''); + await expect(settingsPanel.getByRole('listitem')).toHaveCount(8); + await expect(dialog.getByText('Absent settings', { exact: true })).toBeVisible(); + await expect(dialog.getByText('App not included', { exact: true })).toBeVisible(); + + await expect(dialog.getByText('1 captured entry', { exact: true })).toHaveCount(0); + await expect(dialog.getByText('apps.hidden-module-id', { exact: true })).toHaveCount(0); + await expect(dialog.getByText(PROFILE_PATH, { exact: true })).toHaveCount(0); + await expect(dialog.getByText('Some settings inventory could not be verified.', { exact: true })).toBeVisible(); + + // Both queries are deliberately still populated when the dialog closes; + // reopening must start a fresh inspection session for both tabs. + await settingsSearch.fill('hidden-module-id'); + await expect(settingsPanel.getByRole('listitem')).toHaveCount(1); + + await dialog.getByRole('button', { name: 'Close', exact: true }).first().click(); + await card.getByRole('button', { name: "What's inside hugo-desktop" }).click(); + await expect(dialog.getByRole('searchbox', { name: 'Search apps' })).toHaveValue(''); + await settingsTab.click(); + await expect(dialog.getByRole('searchbox', { name: 'Search app settings' })).toHaveValue(''); + + const commands = await page.evaluate(() => (window as any).__ENDSTATE_E2E_COMMANDS__); + expect(commands).toContainEqual({ command: 'profile', args: ['inspect', PROFILE_PATH] }); + expect(commands.some((entry: { command: string }) => + ['apply', 'preview', 'detect'].includes(entry.command), + )).toBe(false); + await expect(page.getByText('Preview complete', { exact: true })).toHaveCount(0); + }); +}); diff --git a/e2e/real-engine/helpers/bridge.ts b/e2e/real-engine/helpers/bridge.ts index ace849f1..c5d3b48c 100644 --- a/e2e/real-engine/helpers/bridge.ts +++ b/e2e/real-engine/helpers/bridge.ts @@ -1,4 +1,8 @@ import type { APIRequestContext, Page } from '@playwright/test'; +import { randomUUID } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import path from 'node:path'; /** * Helpers for the real-engine lane. These talk to the standalone dev bridge @@ -41,6 +45,64 @@ export interface SeededProfile { name: string; } +export interface SeededInspectionProfile extends SeededProfile { + /** Every copied raw-profile file, retained for precise cleanup. */ + copiedPaths: string[]; + /** Unique profile directory created for this particular test run. */ + directory: string; + /** Parent profiles root used to prove cleanup cannot escape this seed. */ + profilesDirectory: string; +} + +const INSPECTION_FIXTURE_FILES = [ + 'manifest.jsonc', + 'metadata.json', + 'provenance/modules/included.json', + 'provenance/modules/absent.json', +]; + +/** + * Copy the committed extracted profile fixture, including the sibling metadata + * and provenance snapshots that `profile inspect` reads. Importing just a + * manifest would test a different, incomplete profile shape. + */ +export async function seedInspectionProfile( + request: APIRequestContext, +): Promise { + const profilesDir = await bridgeInvoke(request, 'get_default_profiles_directory'); + const fixtureRoot = path.resolve(process.cwd(), 'tests/fixtures/profile-inspect-profile'); + const name = `ci-profile-inspection-${randomUUID()}`; + const profilesRoot = path.resolve(profilesDir); + const destinationRoot = path.join(profilesRoot, name); + if (path.dirname(destinationRoot) !== profilesRoot || existsSync(destinationRoot)) { + throw new Error(`Refusing to seed profile into an unsafe directory: ${destinationRoot}`); + } + + const seeded: SeededInspectionProfile = { + path: path.join(destinationRoot, 'manifest.jsonc'), + name, + copiedPaths: [], + directory: destinationRoot, + profilesDirectory: profilesRoot, + }; + + try { + for (const relativePath of INSPECTION_FIXTURE_FILES) { + const destination = path.join(destinationRoot, relativePath); + await bridgeInvoke(request, 'copy_file', { + sourcePath: path.join(fixtureRoot, relativePath), + destPath: destination, + }); + seeded.copiedPaths.push(destination); + } + } catch (error) { + await removeInspectionProfile(request, seeded); + throw error; + } + + return seeded; +} + /** * Write a real manifest into the engine's default profiles directory through * the same transactional import command the drop zone uses. Clears any prior @@ -72,6 +134,26 @@ export async function removeProfile(request: APIRequestContext, path: string): P await bridgeInvoke(request, 'delete_file_silent', { path }).catch(() => {}); } +/** Remove all raw fixture files written by seedInspectionProfile. */ +export async function removeInspectionProfile( + request: APIRequestContext, + profile: SeededInspectionProfile, +): Promise { + const directory = path.resolve(profile.directory); + const profilesRoot = path.resolve( + await bridgeInvoke(request, 'get_default_profiles_directory'), + ); + if ( + !/^ci-profile-inspection-[a-z0-9-]+$/i.test(profile.name) || + path.basename(directory) !== profile.name || + path.dirname(directory) !== profilesRoot || + profilesRoot !== path.resolve(profile.profilesDirectory) + ) { + throw new Error(`Refusing to remove a non-test inspection directory: ${directory}`); + } + await rm(directory, { recursive: true, force: true }); +} + export interface ApplyAction { id: string; ref: string; @@ -89,6 +171,70 @@ export interface ApplyEnvelope { }; } +export interface ProfileInspectionEnvelope { + schemaVersion: string; + command: 'profile'; + success: true; + error: null; + data: { + profile: { + name: string | null; + capturedAt: string | null; + manifestVersion: number; + manifestPath: string; + }; + apps: Array<{ + id: string; + displayName: string; + hasSettings: boolean; + }>; + settingsApps: Array<{ + id: string; + displayName: string; + associationStatus: 'included' | 'not_in_profile' | 'ambiguous' | 'unresolved'; + appId: string | null; + appIncluded: boolean; + }>; + warnings: Array<{ code: string; message: string; impact: 'diagnostic' | 'inventory_incomplete' }>; + summary: { + appCount: number; + settingsRowCount: number; + verifiedSettingsAppCount: number; + unidentifiedSettingsRowCount: number; + }; + }; +} + +/** Ground-truth, validated `profile inspect` response from the real engine. */ +export async function profileInspectionEnvelope( + request: APIRequestContext, + profilePath: string, +): Promise { + const exec = await bridgeInvoke<{ stdout: string; stderr: string; exitCode: number }>( + request, + 'endstate_exec', + { exe: '__bundled__', args: ['profile', 'inspect', profilePath, '--json'] }, + ); + if (exec.exitCode !== 0) { + throw new Error(`profile inspect exited ${exec.exitCode}: ${exec.stderr.trim()}`); + } + const envelope = JSON.parse(exec.stdout) as ProfileInspectionEnvelope; + if ( + !/^1\./.test(envelope.schemaVersion) || + envelope.command !== 'profile' || + envelope.success !== true || + envelope.error !== null || + !Array.isArray(envelope.data?.apps) || + !Array.isArray(envelope.data?.settingsApps) || + !Array.isArray(envelope.data?.warnings) || + envelope.data.summary.appCount !== envelope.data.apps.length || + envelope.data.summary.settingsRowCount !== envelope.data.settingsApps.length + ) { + throw new Error('Real engine returned an incompatible profile inspection envelope'); + } + return envelope; +} + /** * Ground-truth dry-run apply envelope straight from the engine via the bridge. * Used to assert the GUI rendered what the engine actually reported, rather diff --git a/e2e/real-engine/profile-contents-inspection.spec.ts b/e2e/real-engine/profile-contents-inspection.spec.ts new file mode 100644 index 00000000..f1dceccc --- /dev/null +++ b/e2e/real-engine/profile-contents-inspection.spec.ts @@ -0,0 +1,83 @@ +import { test, expect } from '@playwright/test'; +import { + profileInspectionEnvelope, + removeInspectionProfile, + seedInspectionProfile, + type SeededInspectionProfile, +} from './helpers/bridge'; + +test.describe('real-engine profile contents inspection', () => { + let seeded: SeededInspectionProfile; + + test.beforeEach(async ({ request, page }) => { + seeded = await seedInspectionProfile(request); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await page.getByTestId('intent-setup').click(); + await expect(page.getByTestId('setup-flow')).toBeVisible(); + }); + + test.afterEach(async ({ request }) => { + if (seeded) await removeInspectionProfile(request, seeded); + }); + + test('renders the real profile inspect inventory without selecting or previewing it', async ({ page, request }) => { + expect(seeded.name).toMatch(/^ci-profile-inspection-[a-z0-9-]+$/); + const expected = await profileInspectionEnvelope(request, seeded.path); + const browserInvokes: Array<{ cmd?: string; args?: { args?: string[] } }> = []; + page.on('request', (requestEvent) => { + if (!requestEvent.url().endsWith('/api/invoke') || requestEvent.method() !== 'POST') return; + try { + browserInvokes.push(requestEvent.postDataJSON()); + } catch { + // Non-JSON browser traffic is irrelevant to the bridge command boundary. + } + }); + + const card = page.getByTestId(`profile-card-${seeded.name}`); + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.getByRole('button', { name: `What's inside ${seeded.name}` }).click(); + + const dialog = page.getByRole('dialog', { name: "What's inside" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('tab', { name: `Apps (${expected.data.apps.length})` })).toBeVisible(); + await expect(dialog.getByRole('tab', { name: `App settings (${expected.data.settingsApps.length})` })).toBeVisible(); + await expect( + dialog.getByText( + `Settings for ${expected.data.summary.verifiedSettingsAppCount} apps`, + { exact: true }, + ), + ).toBeVisible(); + + const appsPanel = dialog.getByRole('tabpanel'); + const appLabels = await appsPanel.getByRole('listitem').evaluateAll((items) => + items.map((item) => item.firstElementChild?.textContent?.trim()), + ); + expect(appLabels).toEqual(expected.data.apps.map((app) => app.displayName)); + + const settingsTab = dialog.getByRole('tab', { name: `App settings (${expected.data.settingsApps.length})` }); + await settingsTab.click(); + const settingsPanel = dialog.getByRole('tabpanel'); + const settingsLabels = await settingsPanel.getByRole('listitem').evaluateAll((items) => + items.map((item) => item.firstElementChild?.textContent?.trim()), + ); + expect(settingsLabels).toEqual(expected.data.settingsApps.map((row) => row.displayName)); + for (const row of expected.data.settingsApps.filter((row) => row.associationStatus === 'not_in_profile')) { + await expect(settingsPanel.getByText(row.displayName, { exact: true }).locator('..')).toContainText('App not included'); + } + + if (expected.data.profile.capturedAt === null) { + await expect(dialog.getByText('No capture date recorded', { exact: true })).toBeVisible(); + } else { + const captured = await page.evaluate((value) => new Date(value).toLocaleString(), expected.data.profile.capturedAt); + await expect(dialog.getByText(`captured ${captured}`, { exact: false })).toBeVisible(); + } + + const inspectionInvokes = browserInvokes.filter((entry) => + entry.cmd === 'endstate_exec' && entry.args?.args?.[0] === 'profile', + ); + expect(inspectionInvokes.some((entry) => entry.args?.args?.[1] === '--json' && entry.args.args[2] === 'inspect' && entry.args.args[3] === seeded.path)).toBe(true); + expect(browserInvokes.some((entry) => entry.cmd === 'endstate_exec' && entry.args?.args?.[0] === 'apply')).toBe(false); + await expect(page.getByText('Preview complete', { exact: true })).toHaveCount(0); + }); +}); diff --git a/openspec/changes/fix-profile-contents-accuracy/.openspec.yaml b/openspec/changes/fix-profile-contents-accuracy/.openspec.yaml new file mode 100644 index 00000000..ab396754 --- /dev/null +++ b/openspec/changes/fix-profile-contents-accuracy/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-30 diff --git a/openspec/changes/fix-profile-contents-accuracy/design.md b/openspec/changes/fix-profile-contents-accuracy/design.md new file mode 100644 index 00000000..ef968004 --- /dev/null +++ b/openspec/changes/fix-profile-contents-accuracy/design.md @@ -0,0 +1,139 @@ +## Context + +The 3.9.0 **What's inside** dialog reads an extracted manifest directly in the GUI and builds two sequential lists. Its `settingsModuleCount` counts distinct settings modules, while its rendered `settings` array contains only modules whose ids happen to resolve to friendly labels. A real legacy profile therefore reports **8 settings** but renders no settings rows: ids such as `obsidian` do not exactly match captured app ids such as `obsidian-obsidian` or package refs such as `Obsidian.Obsidian`. + +There are three separate problems: + +1. **Semantic:** a settings-module count is the number of apps with captured settings, not a number of individual settings. +2. **Contract:** the GUI is independently interpreting profile semantics even though the engine and its module catalog own those semantics. +3. **Navigation:** a single scrolling document makes the user pass a long app list before reaching app settings. + +The profile itself must remain authoritative for what it owns. Current catalog data can improve a label or association, but cannot make an uncaptured settings module appear. Inspection must also remain distinct from `apply --dry-run`: it describes the saved artifact and never evaluates the current machine. + +## Goals / Non-Goals + +**Goals:** + +- Describe settings as belonging to apps and keep every owned settings module represented. +- Provide polished, searchable **Apps** and **App settings** tabs. +- Move profile interpretation and legacy label resolution into a narrow read-only engine contract. +- Keep counts and rendered inventories structurally consistent. +- Preserve progressive disclosure for package refs, module ids, captured-file counts, and paths. +- Ship the engine contract before the GUI depends on it and capability-gate the integration. + +**Non-Goals:** + +- Detect whether apps or settings are present on the current computer. +- Reuse apply preview, synthesize setup rows, or change setup/apply behavior. +- Select which settings will be restored from this dialog. +- Display individual setting values or treat captured files as settings. +- Change the profile or capture-bundle format. +- Add fuzzy ownership inference in the GUI. + +## Decisions + +### 1. Add a dedicated `profile inspect` engine operation + +The engine will expose `endstate profile inspect --json`. The first version intentionally accepts the extracted manifest path already held by `DiscoveredProfile`; it may read the manifest's existing sibling metadata and verified `provenance/modules/` snapshots. It returns a normal schema-1.x envelope whose data includes: + +- profile metadata (`name`, capture time, and manifest version); +- `apps[]` with stable identity, friendly display name, package refs, and `hasSettings`; +- `settingsApps[]` with stable app-settings identity, friendly display name, an explicit `associationStatus`, associated app/owner identity when verified, `appIncluded`, contributing module ids for diagnostics, and captured-entry count; +- engine-authored warnings carrying diagnostic or presentation-affecting impact. + +The command will not invoke drivers, path-exists matchers, package detection, planning, preview, restore resolution, or mutation. Arrays will always be non-null and returned in deterministic display order. + +The engine may also return summary counts for CLI consumers, but it must construct them from the finalized arrays. The GUI will render tab totals from `apps.length` and `settingsApps.length`. It will describe only uniquely owned/grouped rows as **Settings for N apps** and will report ambiguous or unresolved rows separately as unidentified, so neither omission nor uncertain ownership can inflate an app count. + +**Alternatives considered:** + +- `profile validate` is too shallow and does not resolve settings ownership or labels. +- `apply --dry-run` already carries some names, but it plans against the current machine and would collapse inspection into preview. +- Keeping a GUI-only parser is smaller locally, but it duplicates engine semantics and cannot reliably label legacy profiles. +- Accepting zip bundles and profile directories would make the new CLI command more general, but the GUI has no such caller and the extra extraction/cleanup behavior is unnecessary for this patch. + +### 2. Advertise inspection as an additive capability + +The capabilities envelope will add `features.profileInspection: true`. This is an additive schema-1.x field and avoids pretending that a subcommand is a flag in `commands.profile.flags`. + +The GUI will call `profile inspect` only when advertised. A non-advertising engine will show an honest unsupported-state message asking the user to update Endstate; it will not fall back to guessing settings names or associations. The released GUI will bundle the matching engine, so this state primarily protects custom or stale engine installations. + +**Alternative considered:** adding a generic `subcommands` array to every command capability is cleaner long term but broadens an urgent fix beyond what this feature needs. + +### 3. Separate ownership evidence from label enrichment + +The engine will first determine settings ownership from the saved profile, then enrich each owned row: + +- Manifest v2: distinct module ids from `configCaptures` and any declared legacy config lanes, deduplicated before presentation. +- Manifest v1: explicit `restore[].fromModule`, declared config-module metadata, and legacy bundle metadata in authority order; old `configs//...` restore sources remain last-resort profile evidence. + +Friendly-name resolution will use: + +1. a verified embedded module-snapshot display name for v2; +2. captured module metadata when present; +3. the current catalog display name for the already-owned module; +4. an associated manifest app display name or package ref; +5. a deterministic human-readable rendering of the short module id. + +Association uses captured evidence and catalog package refs. It determines `appIncluded` and `hasSettings`, never whether the settings module belongs to the profile. Each `settingsApps[]` row carries one of four association states: + +- `included`: exactly one Apps-inventory entry owns the settings; +- `not_in_profile`: a stable app owner is known, but that app is absent from the Apps inventory; +- `ambiguous`: more than one Apps-inventory entry is a plausible owner; +- `unresolved`: no stable app owner can be established. + +Every owned module contributes to exactly one row. Modules are grouped only when they share the same verified owner identity; ambiguous and unresolved modules remain separate so evidence is never discarded. Multiple config sets or instances for one module are likewise deduplicated. `apps[].hasSettings` is true if and only if at least one `included` settings row uniquely references that app. Ambiguous rows mark no app. + +An ambiguous, unresolved, or removed module remains present with neutral unidentified-app copy. It contributes to the **App settings** tab total but not to **Settings for N apps**; the normal summary reports the unidentified row count separately. Its raw id and any ambiguous candidates are available only in technical details. + +**Alternative considered:** exact id matching against manifest apps is what failed for the real legacy profile and is not sufficient. + +### 4. Use two compact, searchable inventories + +The dialog will use an accessible tab control: + +- **Apps (N)** is initially active when the profile contains at least one app entry. +- **App settings (N)** is initially active for a settings-only profile. + +Each tab owns one scrollable list and one search field. Search is case-insensitive and matches the engine-provided friendly label plus package/module identifiers, while technical identifiers remain visually hidden unless details are enabled. Changing profile or reopening the dialog clears search; switching tabs does not mutate either inventory. + +App rows use the same compact icon/label/status rhythm as Setup preview. An app with uniquely associated captured settings receives a quiet settings indicator. App-settings rows use the friendly app name; `not_in_profile` rows carry the muted secondary copy **App not included**, while ambiguous/unresolved rows use engine-authored unidentified copy. Search with no matches produces a local no-results state without changing the tab total. + +The dialog grows to a medium width, while header, tab/search controls, and footer remain fixed and only the active list scrolls. + +**Alternatives considered:** + +- A unified app list makes settings-only apps and the meaning of the app count harder to explain. +- Two sequential sections preserve the current long-scroll problem. + +### 5. Keep implementation details behind disclosure + +Default rows will not show captured-file counts. **Configuration details**, when globally enabled and explicitly opened, may show package refs, module ids, captured-entry counts, manifest version, diagnostic-only warnings, and path. This retains diagnostic value without presenting files as user settings. + +An engine warning whose impact means the inventory may be incomplete is different: its engine-authored message remains visible in the normal dialog and the UI does not claim an unconditional complete inventory. The GUI does not infer warning impact from text. + +Read/parse failures remain explicit errors rather than valid empty profiles. A genuinely empty inventory is calm and descriptive, not a warning. + +## Risks / Trade-offs + +- **Cross-repository release ordering** → Merge and release the additive engine contract first, then update the GUI's bundled-engine revision and capability-gated consumer. +- **Legacy artifacts contain incomplete metadata** → Resolve labels through the catalog only after ownership is established; always keep one row per owned module and use a neutral fallback. +- **Duplicate summary and array counts drift** → Build engine summary counts from finalized arrays and derive GUI totals from those same arrays. +- **Ambiguous associations mark the wrong app** → Model association state explicitly; only a unique `included` reference can set `hasSettings`, and unidentified rows are counted separately. +- **Inspection could accidentally become machine evaluation** → Contract and test that no drivers, matchers, planner, or apply path are invoked. +- **New tabs regress accessibility** → Use a proper tab primitive or implement the complete tablist keyboard/ARIA contract, and cover it with component tests. +- **Opening inspection against a stale external engine fails** → Capability-gate and provide a specific update message instead of silently degrading into inaccurate data. + +## Migration Plan + +1. Add and validate the engine OpenSpec/contract delta for `profile inspect` and `features.profileInspection`. +2. Implement and release the engine command with hermetic v1/v2 fixtures, including the real legacy id-mismatch shape and ambiguous/unresolved associations. +3. Update the GUI's bundled-engine revision and structured TypeScript contract. +4. Replace the local profile-content summarizer with the capability-gated inspection call and implement the two-tab modal. +5. Verify unit/contract tests and drive the real modal against a large legacy profile before the GUI patch release. + +Rollback is straightforward: revert the GUI consumer while leaving the additive engine command in place. The engine addition has no profile migration and remains backward compatible. + +## Open Questions + +None. The user-approved product shape is two tabs with scoped search, app-owned settings language, complete rows, and technical counts hidden by default. diff --git a/openspec/changes/fix-profile-contents-accuracy/proposal.md b/openspec/changes/fix-profile-contents-accuracy/proposal.md new file mode 100644 index 00000000..95b48822 --- /dev/null +++ b/openspec/changes/fix-profile-contents-accuracy/proposal.md @@ -0,0 +1,29 @@ +## Why + +The released **What's inside** dialog describes distinct app-settings modules as individual "settings" and can omit every settings row from older profiles when module ids do not exactly match captured app ids. That makes a read-only trust surface misleading precisely when users are deciding whether a profile is safe and useful to apply. + +## What Changes + +- Present profile contents as two searchable tabs: **Apps** and **App settings**. +- Replace ambiguous counts such as **8 settings** with explicit ownership language such as **Settings for 8 apps**. +- Keep every profile-owned settings module represented in an app-settings row, grouping modules only when they have the same verified app owner, retaining settings-only apps, and reporting unidentified ownership separately instead of inflating the app count. +- Resolve app-settings labels through a narrow read-only engine inspection contract while keeping the profile manifest authoritative for ownership and counts. +- Show captured-file counts, package refs, module ids, and profile paths only under **Configuration details**; they are not setting counts and do not belong in the default summary. +- Keep inspection independent of profile selection, machine preview, and apply. Opening the dialog may inspect the saved profile but MUST NOT evaluate or change the current machine. + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities + +- `profile-contents-inspection`: Correct settings ownership semantics, require complete app-settings representation, add tabbed/searchable navigation, and replace the local-only inspection restriction with a read-only engine inspection contract. + +## Impact + +- GUI profile-content modeling and presentation in `src/lib/profile-contents.ts` and `src/components/app/intent/profile-contents-modal.tsx`. +- Set up flow wiring, engine capability detection/invocation, TypeScript contract types, and targeted unit/E2E coverage. +- Coordinated Endstate engine work for a structured read-only profile inspection envelope and capability advertisement. +- Bundled engine revision and GUI/engine contract documentation; no new runtime dependency and no profile-format migration. diff --git a/openspec/changes/fix-profile-contents-accuracy/specs/profile-contents-inspection/spec.md b/openspec/changes/fix-profile-contents-accuracy/specs/profile-contents-inspection/spec.md new file mode 100644 index 00000000..88968277 --- /dev/null +++ b/openspec/changes/fix-profile-contents-accuracy/specs/profile-contents-inspection/spec.md @@ -0,0 +1,178 @@ +## ADDED Requirements + +### Requirement: App and app-settings inventories are independently navigable + +The contents summary SHALL present **Apps** and **App settings** as separate tabs with totals derived from the rows in each inventory. The dialog SHALL provide search scoped to the active tab so a user can find an entry without scrolling through the other inventory. + +#### Scenario: User switches directly to app settings +- **WHEN** a profile contains apps and captured app settings +- **THEN** the dialog opens on the **Apps** tab +- **AND** the user can activate **App settings** without scrolling past the apps list + +#### Scenario: Settings-only profile opens its populated tab +- **WHEN** a profile contains captured app settings but no apps in its Apps inventory +- **THEN** the dialog opens on the **App settings** tab +- **AND** the empty apps inventory remains available as a separate tab + +#### Scenario: Search filters only the active inventory +- **WHEN** the user searches in either tab by friendly name or package identifier +- **THEN** matching rows in the active inventory remain visible +- **AND** the tab totals continue to describe the complete profile rather than the filtered result +- **AND** the inactive inventory is unchanged + +#### Scenario: Search has no matches +- **WHEN** the active inventory contains no row matching the search query +- **THEN** the dialog shows a calm no-results message +- **AND** it does not present the profile as empty or invalid + +#### Scenario: Tab controls are keyboard operable +- **WHEN** keyboard focus is within the tab control +- **THEN** the user can move between **Apps** and **App settings** using standard tab keyboard behavior +- **AND** focus and selected-state semantics are exposed to assistive technology + +## MODIFIED Requirements + +### Requirement: Inspecting a profile changes nothing + +Opening the contents summary SHALL be read-only. The GUI MAY invoke the engine's dedicated profile-inspection command, but SHALL NOT select the profile, request a machine preview, run app or settings detection, or modify the machine as a result of inspection. + +#### Scenario: Inspection does not start a setup run +- **WHEN** the user activates **What's inside** on a profile card +- **THEN** the profile is not selected +- **AND** no apply preview is requested +- **AND** no app or settings detection is run +- **AND** no apply command is invoked + +#### Scenario: Inspection uses its own read-only command boundary +- **WHEN** the GUI needs structured profile contents from the engine +- **THEN** it invokes only the dedicated profile-inspection command +- **AND** the command reads the saved profile without evaluating the current machine + +### Requirement: Summary reports what the profile will apply + +The summary SHALL report the capture timestamp when the profile records one, the number of applications in its Apps inventory, and the distinct verified applications for which the profile carries settings. The default UI SHALL describe the latter as **Settings for N apps**, SHALL ensure every profile-owned settings module contributes to an app-settings row, and SHALL NOT describe captured files or restore entries as individual settings. Multiple owned modules SHALL be grouped only when they share the same verified application owner. Ambiguous or unresolved rows SHALL remain visible but SHALL be reported separately as unidentified instead of inflating the application count. + +#### Scenario: Apps and app settings are counted semantically +- **WHEN** a profile contains 72 app entries and settings modules associated with 8 distinct apps +- **THEN** the summary reports **72 apps** +- **AND** it reports **Settings for 8 apps** +- **AND** the **App settings** inventory contains 8 rows + +#### Scenario: Application is named +- **WHEN** the inspection result carries an application display name +- **THEN** the **Apps** inventory lists the application by that display name +- **AND** its package identifier remains searchable + +#### Scenario: Application carries settings +- **WHEN** an application in the Apps inventory is associated with a profile-owned settings module +- **THEN** its row in the **Apps** inventory shows a subtle settings-included indicator +- **AND** the same application appears once in the **App settings** inventory + +#### Scenario: Settings-only application remains visible +- **WHEN** a profile owns settings for an application that is absent from its Apps inventory +- **THEN** the **App settings** inventory contains that application +- **AND** the row calmly states that the app is not included +- **AND** the application is not added to the Apps total + +#### Scenario: Unidentified settings ownership does not inflate the app count +- **WHEN** one or more owned settings modules cannot be uniquely associated with an application +- **THEN** each module remains represented in the **App settings** inventory +- **AND** those rows do not increase the **Settings for N apps** count +- **AND** the normal summary reports the number of unidentified rows separately + +#### Scenario: Capture file counts stay technical +- **WHEN** an app-settings module contains one or more captured files or restore entries +- **THEN** those entry counts do not appear in the default inventory row +- **AND** they MAY appear under **Configuration details** + +#### Scenario: Capture timestamp is shown when recorded +- **WHEN** the profile records a capture timestamp +- **THEN** the summary shows when the profile was captured + +#### Scenario: Capture timestamp is absent +- **WHEN** the profile records no capture timestamp +- **THEN** the summary states that no capture date is recorded +- **AND** no date is invented + +### Requirement: Settings-free and app-free profiles read as normal outcomes + +A profile carrying no settings, or no applications, SHALL be described in calm, explanatory language. The GUI SHALL NOT present either case as a warning or an error, and SHALL select the populated tab when only one inventory contains rows. + +#### Scenario: Install-only profile +- **WHEN** a profile declares applications but no captured settings +- **THEN** the summary states that the profile includes apps but no app settings +- **AND** no warning or error treatment is applied + +#### Scenario: Settings-only profile +- **WHEN** a profile declares captured settings but no applications +- **THEN** the summary states that the profile carries app settings but includes no apps +- **AND** the **App settings** tab is initially active + +### Requirement: The summary never exposes raw provenance ids + +The default summary SHALL NOT use a module id, capture id, config-set id, or file path as user-facing label text. Every profile-owned settings module SHALL remain represented even when no friendly name or unique application association resolves; such a module SHALL use neutral unidentified-app copy rather than disappearing from the list. Exact identifiers, ambiguous candidates, and the manifest path SHALL be exposed only through **Configuration details**, consistent with `config-generation-presentation`. + +#### Scenario: Unresolvable module remains a visible row +- **WHEN** a profile-owned settings module has no friendly label resolvable from profile evidence or the module catalog +- **THEN** the module still contributes one row to the **App settings** tab total +- **AND** the row uses neutral unidentified-app copy +- **AND** its raw module id does not appear as the default label +- **AND** it does not increase the verified **Settings for N apps** count + +#### Scenario: Identifiers live behind the disclosure +- **WHEN** the show-details setting is on and the user opens **Configuration details** +- **THEN** package refs, module ids, ambiguous association candidates, captured-entry counts, manifest version, and manifest path are shown + +#### Scenario: Disclosure is absent by default +- **WHEN** the show-details setting is off +- **THEN** no **Configuration details** disclosure is offered +- **AND** no module id or file path appears anywhere in the summary + +### Requirement: The summary uses read-only engine profile inspection + +The GUI SHALL consume a structured result from the engine's dedicated profile-inspection command. The saved profile SHALL remain authoritative for application membership, settings ownership, and entry counts; bundle snapshots, captured metadata, and the engine module catalog MAY enrich labels and associations but MUST NOT add settings that the profile does not own. Every association SHALL be classified as uniquely included, known but absent from the Apps inventory, ambiguous, or unresolved. The command SHALL NOT inspect current-machine installation or settings state. + +#### Scenario: Profile evidence determines ownership +- **WHEN** the current engine catalog contains a settings module that the profile did not capture +- **THEN** that module does not appear in the app-settings inventory + +#### Scenario: Bundled module display name is used +- **WHEN** a manifest-v2 config capture references a readable module snapshot inside the bundle +- **THEN** the settings row uses the display name recorded in that snapshot + +#### Scenario: Legacy profile label is enriched from the catalog +- **WHEN** a legacy profile owns a known settings module but lacks a bundled display name +- **THEN** the engine resolves its friendly label from captured metadata or the matching module catalog entry +- **AND** catalog membership does not change the settings total + +#### Scenario: Unique association marks both inventories consistently +- **WHEN** one owned settings row is uniquely associated with an application in the Apps inventory +- **THEN** the row identifies that application with association state `included` +- **AND** that application's Apps row reports that settings are included + +#### Scenario: Ambiguous association marks no application +- **WHEN** an owned settings module plausibly matches more than one application in the Apps inventory +- **THEN** the settings row remains visible with association state `ambiguous` +- **AND** none of the candidate Apps rows is marked as carrying those settings +- **AND** the row is excluded from the verified app-settings count + +#### Scenario: Presentation-affecting warning remains visible +- **WHEN** the inspection result carries an engine-authored warning that says inventory completeness is degraded +- **THEN** the normal dialog shows that engine-authored warning +- **AND** the GUI does not present the inventory as unconditionally complete +- **AND** it does not derive warning impact by parsing warning text + +#### Scenario: Older engine lacks inspection capability +- **WHEN** the active engine does not advertise dedicated profile inspection +- **THEN** the GUI does not fabricate app-settings names or associations +- **AND** it explains that Endstate must be updated to inspect app settings accurately + +#### Scenario: Unreadable profile is surfaced +- **WHEN** the profile manifest cannot be read or parsed +- **THEN** the summary reports that the profile could not be read +- **AND** does not present an empty profile as a valid summary + +## RENAMED Requirements + +- FROM: `### Requirement: The summary reads only extracted profile files` +- TO: `### Requirement: The summary uses read-only engine profile inspection` diff --git a/openspec/changes/fix-profile-contents-accuracy/tasks.md b/openspec/changes/fix-profile-contents-accuracy/tasks.md new file mode 100644 index 00000000..ec306b1a --- /dev/null +++ b/openspec/changes/fix-profile-contents-accuracy/tasks.md @@ -0,0 +1,37 @@ +## 1. Establish The Cross-Repo Contract + +- [x] 1.1 Archive the already-shipped `add-profile-contents-view` GUI change in its own documentation PR so `profile-contents-inspection` becomes current spec truth before this delta is archived. +- [x] 1.2 Create the engine OpenSpec change for `profile inspect`, the additive `features.profileInspection` capability, and the no-machine-evaluation invariant. +- [x] 1.3 Update the engine profile, CLI JSON, and GUI integration contracts with the structured inspection envelope and label/ownership precedence. +- [x] 1.4 Validate both repositories' OpenSpec changes strictly before implementation. + +## 2. Implement Read-Only Engine Inspection + +- [x] 2.1 Add failing engine tests for command routing, capability advertisement, non-null deterministic output, and structured error envelopes. +- [x] 2.2 Add failing profile fixtures/tests for v1 explicit and legacy ownership, the `obsidian` versus `obsidian-obsidian` id mismatch, settings-only apps, ambiguous/unresolved owned modules, and v2 module deduplication/snapshot precedence. +- [x] 2.3 Implement `endstate profile inspect --json` using the existing manifest and module-catalog loaders plus verified sibling metadata/snapshots, without invoking bundle extraction, drivers, matchers, planning, preview, or mutation. +- [x] 2.4 Build summary counts from finalized `apps[]` and `settingsApps[]`, keep every owned settings module represented, classify unique/absent/ambiguous/unresolved associations, and emit impact-typed engine-authored warnings. +- [x] 2.5 Run targeted Go tests plus engine contract/OpenSpec validation, obtain independent review, merge the engine PR, and publish or otherwise pin a consumable engine revision. + +## 3. Consume The Inspection Contract In The GUI + +- [x] 3.1 Add failing GUI contract tests for `features.profileInspection`, the `profile inspect` invocation, schema validation, stale-engine handling, and unreadable-profile errors. +- [x] 3.2 Add typed profile-inspection envelope models and capability detection, then route **What's inside** through the dedicated engine command. +- [x] 3.3 Remove the GUI-owned settings ownership/label inference path while retaining only structural presentation helpers such as search filtering and pluralization. +- [x] 3.4 Update the bundled-engine revision/resources and contract fixtures to the reviewed engine build. + +## 4. Build The Two-Tab Modal + +- [x] 4.1 Add failing component tests for **Apps**/**App settings** totals, default-tab selection, keyboard tab behavior, scoped search, no-results copy, and search reset across profiles. +- [x] 4.2 Add failing regression tests proving **Settings for 8 apps** renders with eight rows, settings-only apps say **App not included**, unidentified rows do not inflate the app count, and captured-file counts stay out of default rows. +- [x] 4.3 Implement the accessible two-tab control, scoped search, compact Setup-style rows, settings-included indicators, medium-width fixed-shell layout, and active-list-only scrolling. +- [x] 4.4 Keep package refs, module ids, ambiguous candidates, captured-entry counts, diagnostic warnings, manifest version, and path inside **Configuration details**, while rendering presentation-affecting warnings in the normal dialog. +- [x] 4.5 Update Set up flow integration tests to prove inspection remains independent of selection, preview, machine detection, and apply. + +## 5. Verify And Release The GUI Fix + +- [x] 5.1 Run the targeted profile-content/component/Setup tests, TypeScript check, production build, contract tests, and strict OpenSpec validation. +- [x] 5.2 Drive the real GUI against a large legacy profile and verify both tabs, all eight app-settings rows, search, settings-only labeling, keyboard behavior, and disclosure content. +- [x] 5.3 Run the project shadow check and independent code/security review; address every correctness or contract finding. +- [ ] 5.4 Commit with a patch-triggering conventional message, open the GUI PR with engine dependency and verification evidence, and merge after CI/review. +- [ ] 5.5 Merge the resulting release-please PR and verify the published GUI release, updater manifest, signatures, and installer assets. diff --git a/src/App.tsx b/src/App.tsx index b035a670..c093770a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,6 +51,7 @@ import { loadSidebarVisible, saveSidebarVisible } from './lib/ui-mode'; import { IntentLanding, NativeProfileDropFeedback, SaveFlow, SetupFlow } from './components/app/intent'; import { getProfilesDirectory, ensureDirectory, isTauriRuntime, openFolder, invoke, listen } from './lib/tauri-bridge'; import { runEndstateOnce, getErrorMessage, buildEngineCommand } from './lib/engine-exec'; +import { inspectProfileContents } from './lib/profile-contents'; import { shouldDeleteCaptureArtifact } from './lib/capture-artifact-lifecycle'; import { saveProfileMetadata, deleteProfileFiles } from './lib/profile-metadata'; import { validateProfileFilename, getExtension, type ValidExtension } from './lib/filename-validation'; @@ -3055,6 +3056,8 @@ function AppContent() { liveConfigEvents={liveConfigEvents} applyOnlySupported={applyOnlySupported} restoreTargetSupported={restoreTargetSupported} + profileInspectionSupported={state.capabilities?.data?.features?.profileInspection === true} + onInspectProfile={(manifestPath) => inspectProfileContents(settings, manifestPath)} onPreview={async (profile, previewOptions) => { setIsRunning(true); setLiveAppEvents([]); diff --git a/src/components/app/intent/profile-contents-modal.test.tsx b/src/components/app/intent/profile-contents-modal.test.tsx index 02f20842..5b5e7de1 100644 --- a/src/components/app/intent/profile-contents-modal.test.tsx +++ b/src/components/app/intent/profile-contents-modal.test.tsx @@ -1,232 +1,548 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor, within } from '../../../test/test-utils'; -import userEvent from '@testing-library/user-event'; -import '@testing-library/jest-dom/vitest'; -import { ProfileContentsModal } from './profile-contents-modal'; -import { useShowDetails } from '@/lib/use-show-details'; - -vi.mock('@/lib/tauri-bridge', () => ({ - invoke: vi.fn(), -})); - -vi.mock('@/lib/use-show-details', () => ({ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor, within } from "../../../test/test-utils"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom/vitest"; +import { ProfileContentsModal } from "./profile-contents-modal"; +import { useShowDetails } from "@/lib/use-show-details"; +import type { ProfileInspectionData } from "@/types"; + +vi.mock("@/lib/use-show-details", () => ({ useShowDetails: vi.fn(() => false), })); const defaultProps = { open: true, onOpenChange: vi.fn(), - profilePath: 'C:\\Setups\\my-desktop\\manifest.jsonc', - profileDisplayName: 'My desktop', + profilePath: "C:\\Setups\\my-desktop\\manifest.jsonc", + profileDisplayName: "My desktop", + profileInspectionSupported: true, }; -/** A v1 profile: two apps, three restore entries across two modules. */ -const V1_MANIFEST = JSON.stringify({ - version: 1, - name: 'my-desktop', - captured: '2026-07-18T12:00:00Z', - apps: [ - { id: 'vlc', displayName: 'VLC media player', refs: { windows: 'VideoLAN.VLC' } }, - { id: 'notepad-plus-plus', displayName: 'Notepad++', refs: { windows: 'Notepad++.Notepad++' } }, - ], - restore: [ - { type: 'copy', source: './configs/vlc/vlcrc' }, - { type: 'copy', source: './configs/notepad-plus-plus/config.xml' }, - { type: 'copy', source: './configs/notepad-plus-plus/shortcuts.xml' }, - ], -}); - -async function mockManifest(content: string) { - const { invoke } = await import('@/lib/tauri-bridge'); - vi.mocked(invoke).mockResolvedValue(content); +function inspection( + overrides: Partial = {}, +): ProfileInspectionData { + const apps = [ + { + id: "app:vlc:1", + manifestAppId: "vlc", + displayName: "VLC media player", + packageRefs: ["VideoLAN.VLC"], + hasSettings: true, + }, + { + id: "app:obsidian:1", + manifestAppId: "obsidian", + displayName: "Obsidian", + packageRefs: ["Obsidian.Obsidian"], + hasSettings: false, + }, + ]; + const settingsApps = [ + { + id: "settings:vlc", + displayName: "VLC media player", + associationStatus: "included" as const, + ownerId: "app:vlc:1", + appId: "app:vlc:1", + appIncluded: true, + packageRefs: ["VideoLAN.VLC"], + moduleIds: ["apps.vlc"], + candidateAppIds: ["app:vlc:1"], + capturedEntryCount: 2, + }, + { + id: "settings:steam", + displayName: "Steam", + associationStatus: "not_in_profile" as const, + ownerId: "owner:steam", + appId: null, + appIncluded: false, + packageRefs: ["Valve.Steam"], + moduleIds: ["apps.steam"], + candidateAppIds: [], + capturedEntryCount: 1, + }, + { + id: "settings:unknown", + displayName: "Unidentified app settings", + associationStatus: "unresolved" as const, + ownerId: null, + appId: null, + appIncluded: false, + packageRefs: [], + moduleIds: ["apps.unknown"], + candidateAppIds: [], + capturedEntryCount: 3, + }, + ]; + return { + profile: { + name: "my-desktop", + capturedAt: "2026-07-18T12:00:00Z", + manifestVersion: 2, + manifestPath: defaultProps.profilePath, + }, + summary: { + appCount: apps.length, + settingsRowCount: settingsApps.length, + verifiedSettingsAppCount: 2, + unidentifiedSettingsRowCount: 1, + }, + apps, + settingsApps, + warnings: [], + ...overrides, + }; } -/** The Apps / Settings sections are named regions, so each can be queried alone. */ -const appsSection = () => screen.findByRole('region', { name: 'Apps' }); -const settingsSection = () => screen.findByRole('region', { name: 'Settings' }); +function renderModal( + data = inspection(), + props: Partial> = {}, +) { + const onInspectProfile = vi.fn().mockResolvedValue(data); + render( + , + ); + return { onInspectProfile }; +} -describe('ProfileContentsModal', () => { +describe("ProfileContentsModal", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(useShowDetails).mockReturnValue(false); }); - it('renders nothing when closed', () => { - render(); + afterEach(() => { + vi.restoreAllMocks(); + }); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + it("renders semantic Apps and App settings totals from the engine result", async () => { + renderModal(); + + expect(await screen.findByText("2 apps")).toBeVisible(); + expect(screen.getByText("3 app settings")).toBeVisible(); + expect(screen.getByText("Settings for 2 apps")).toBeVisible(); + expect(screen.getByText("1 unidentified app settings row")).toBeVisible(); }); - it('renders app and settings counts from the manifest', async () => { - await mockManifest(V1_MANIFEST); + it("describes 72 apps and settings for 8 apps without counting files as settings", async () => { + const apps = Array.from({ length: 72 }, (_, index) => ({ + id: `app:${index}`, + manifestAppId: `app-${index}`, + displayName: `App ${index}`, + packageRefs: [], + hasSettings: index < 8, + })); + const settingsApps = apps.slice(0, 8).map((app, index) => ({ + id: `settings:${index}`, + displayName: app.displayName, + associationStatus: "included" as const, + ownerId: app.id, + appId: app.id, + appIncluded: true, + packageRefs: [], + moduleIds: [`apps.${index}`], + candidateAppIds: [app.id], + capturedEntryCount: index + 1, + })); + renderModal( + inspection({ + apps, + settingsApps, + summary: { + appCount: 72, + settingsRowCount: 8, + verifiedSettingsAppCount: 8, + unidentifiedSettingsRowCount: 0, + }, + }), + ); - render(); + expect(await screen.findByText("72 apps")).toBeVisible(); + expect(screen.getByText("8 app settings")).toBeVisible(); + expect(screen.getByText("Settings for 8 apps")).toBeVisible(); + expect(screen.queryByText("1 captured entry")).not.toBeInTheDocument(); + }); - const dialog = await screen.findByRole('dialog'); - await waitFor(() => { - expect(within(dialog).getByText('2 apps')).toBeVisible(); - }); - expect(within(dialog).getByText('2 settings')).toBeVisible(); + it("shows only the Apps tab initially and marks settings-bearing apps quietly", async () => { + renderModal(); + + const appsTab = await screen.findByRole("tab", { name: "Apps (2)" }); + expect(appsTab).toHaveAttribute("aria-selected", "true"); + expect( + within(document.getElementById(appsTab.getAttribute("aria-controls")!)!).getByText( + "VLC media player", + ), + ).toBeVisible(); + expect(screen.getByText("Settings included")).toBeVisible(); + expect(screen.getByText("App not included")).not.toBeVisible(); }); - it('lists apps by display name', async () => { - await mockManifest(V1_MANIFEST); + it("uses App settings as the default tab for a settings-only profile", async () => { + const data = inspection({ + apps: [], + summary: { + appCount: 0, + settingsRowCount: 3, + verifiedSettingsAppCount: 2, + unidentifiedSettingsRowCount: 1, + }, + }); + renderModal(data); - render(); + expect( + await screen.findByRole("tab", { name: "App settings (3)" }), + ).toHaveAttribute("aria-selected", "true"); + expect( + screen.getByText( + "This profile carries app settings but includes no apps.", + ), + ).toBeVisible(); + }); - const apps = await appsSection(); - expect(within(apps).getByText('VLC media player')).toBeVisible(); - expect(within(apps).getByText('Notepad++')).toBeVisible(); + it("supports standard keyboard tab activation and focus movement", async () => { + const user = userEvent.setup(); + renderModal(); + + const apps = await screen.findByRole("tab", { name: "Apps (2)" }); + const settings = screen.getByRole("tab", { name: "App settings (3)" }); + apps.focus(); + await user.keyboard("{ArrowRight}"); + expect(settings).toHaveFocus(); + expect(settings).toHaveAttribute("aria-selected", "true"); + await user.keyboard("{Home}"); + expect(apps).toHaveFocus(); + expect(apps).toHaveAttribute("aria-selected", "true"); + await user.keyboard("{End}"); + expect(settings).toHaveFocus(); + expect(settings).toHaveAttribute("aria-selected", "true"); }); - it('lists settings modules by display name with their file counts', async () => { - await mockManifest(V1_MANIFEST); + it("wraps tab arrows and keeps every tab's controlled panel in the DOM", async () => { + const user = userEvent.setup(); + renderModal(); + + const apps = await screen.findByRole("tab", { name: "Apps (2)" }); + const settings = screen.getByRole("tab", { name: "App settings (3)" }); + for (const tab of [apps, settings]) { + expect(document.getElementById(tab.getAttribute("aria-controls")!)).toHaveAttribute( + "role", + "tabpanel", + ); + } + + apps.focus(); + await user.keyboard("{ArrowLeft}"); + expect(settings).toHaveFocus(); + expect(settings).toHaveAttribute("aria-selected", "true"); + await user.keyboard("{ArrowRight}"); + expect(apps).toHaveFocus(); + expect(apps).toHaveAttribute("aria-selected", "true"); + }); - render(); + it("scopes search to the active tab and preserves queries when switching", async () => { + const user = userEvent.setup(); + renderModal(); + await screen.findByRole("tab", { name: "Apps (2)" }); - const settings = await settingsSection(); - // VLC contributes one file, Notepad++ two. - expect(within(settings).getByText('VLC media player')).toBeVisible(); - expect(within(settings).getByText('1 file')).toBeVisible(); - expect(within(settings).getByText('Notepad++')).toBeVisible(); - expect(within(settings).getByText('2 files')).toBeVisible(); + await user.type( + screen.getByRole("searchbox", { name: "Search apps" }), + "obsidian", + ); + expect(screen.getByText("Obsidian")).toBeVisible(); + await user.click(screen.getByRole("tab", { name: "App settings (3)" })); + await user.type( + screen.getByRole("searchbox", { name: "Search app settings" }), + "steam", + ); + expect(screen.getByText("Steam")).toBeVisible(); + await user.click(screen.getByRole("tab", { name: "Apps (2)" })); + expect(screen.getByRole("searchbox", { name: "Search apps" })).toHaveValue( + "obsidian", + ); }); - it('falls back to the package ref when an app has no display name', async () => { - await mockManifest( - JSON.stringify({ - version: 1, - apps: [{ id: 'jq', refs: { windows: 'jqlang.jq' } }], + it("matches ordinary search text independently of locale casing rules", async () => { + vi.spyOn(String.prototype, "toLocaleLowerCase") + .mockImplementation(function (this: string) { + return this.replace(/I/g, "ı").toLowerCase(); + }); + const user = userEvent.setup(); + renderModal( + inspection({ + apps: [ + { + id: "app:irfanview:1", + manifestAppId: "irfanview", + displayName: "IrfanView", + packageRefs: [], + hasSettings: false, + }, + ], + settingsApps: [], + summary: { + appCount: 1, + settingsRowCount: 0, + verifiedSettingsAppCount: 0, + unidentifiedSettingsRowCount: 0, + }, }), ); + await screen.findByRole("searchbox", { name: "Search apps" }); - render(); + await user.type( + screen.getByRole("searchbox", { name: "Search apps" }), + "irfanview", + ); - expect(await screen.findByText('jqlang.jq')).toBeVisible(); + expect(screen.getByText("IrfanView")).toBeVisible(); }); - it('shows the captured timestamp', async () => { - await mockManifest(V1_MANIFEST); - - render(); + it("shows calm no-results copy without changing totals", async () => { + const user = userEvent.setup(); + renderModal(); + await screen.findByRole("searchbox", { name: "Search apps" }); - expect(await screen.findByText(/captured/i)).toBeVisible(); + await user.type( + screen.getByRole("searchbox", { name: "Search apps" }), + "missing", + ); + expect(screen.getByText("No apps match “missing”.")).toBeVisible(); + expect(screen.getByRole("tab", { name: "Apps (2)" })).toBeVisible(); }); - it('states calmly that an install-only profile has no settings', async () => { - await mockManifest( - JSON.stringify({ - version: 1, - name: 'apps-only', - apps: [{ id: 'jq', displayName: 'jq' }], - }), + it("labels settings-only apps and leaves unidentified rows unassociated", async () => { + const user = userEvent.setup(); + renderModal(); + await user.click( + await screen.findByRole("tab", { name: "App settings (3)" }), ); - render(); - + expect(screen.getByText("App not included")).toBeVisible(); + expect(screen.getByText("Unidentified app settings")).toBeVisible(); expect( - await screen.findByText('This profile installs apps only — no settings are included.'), + screen.getByText("Association could not be identified."), ).toBeVisible(); - expect(screen.getByText('1 app')).toBeVisible(); - // A settings-free profile is a normal outcome, not a warning. - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); - it('states that a settings-only profile installs no apps', async () => { - await mockManifest( - JSON.stringify({ - version: 1, - apps: [], - restore: [{ type: 'copy', source: './configs/vlc/vlcrc' }], - }), - ); - - render(); + it("keeps technical provenance behind Configuration details", async () => { + vi.mocked(useShowDetails).mockReturnValue(true); + const user = userEvent.setup(); + renderModal(); + await screen.findByText("2 apps"); + expect(screen.queryByText("apps.vlc")).not.toBeInTheDocument(); expect( - await screen.findByText('This profile carries settings only — it installs no apps.'), - ).toBeVisible(); + screen.queryByText(defaultProps.profilePath), + ).not.toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "Configuration details" }), + ); + expect(screen.getByText("apps.vlc")).toBeVisible(); + expect(screen.getByText(defaultProps.profilePath)).toBeVisible(); + expect(screen.getByText("2 captured entries")).toBeVisible(); }); - it('does not leak raw module ids into the summary', async () => { - await mockManifest( - JSON.stringify({ - version: 2, - name: 'capture-v2', - apps: [], - configCaptures: [ - { - captureId: 'photoshop-preferences-installed', - moduleId: 'apps.photoshop', - configSetId: 'preferences', - payloadManifest: [{ relativePath: 'prefs.psp' }], - }, - ], + it("keeps app package refs hidden by default and shows them for install-only profiles in details", async () => { + vi.mocked(useShowDetails).mockReturnValue(true); + const user = userEvent.setup(); + renderModal( + inspection({ + apps: [{ + id: "app:solo:1", manifestAppId: "solo", displayName: "Solo app", + packageRefs: ["Example.Solo"], hasSettings: false, + }], + settingsApps: [], + summary: { appCount: 1, settingsRowCount: 0, verifiedSettingsAppCount: 0, unidentifiedSettingsRowCount: 0 }, }), ); - - render(); - - await screen.findByText('1 setting'); - // The module is counted, but nothing names it with its engine id. - expect(screen.queryByText('apps.photoshop')).not.toBeInTheDocument(); - expect(screen.queryByText('photoshop')).not.toBeInTheDocument(); - expect(screen.queryByText('photoshop-preferences-installed')).not.toBeInTheDocument(); - expect(screen.queryByText('preferences')).not.toBeInTheDocument(); + await screen.findByText("1 app"); + expect(screen.queryByText("Example.Solo")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Configuration details" })); + expect(screen.getByText("Example.Solo")).toBeVisible(); }); - it('reveals module ids and the file path only under Configuration details', async () => { + it("keeps expanded details inside the only scrollable content region", async () => { vi.mocked(useShowDetails).mockReturnValue(true); - await mockManifest(V1_MANIFEST); const user = userEvent.setup(); + renderModal(); + await screen.findByText("2 apps"); + await user.click(screen.getByRole("button", { name: "Configuration details" })); - render(); + const scrollRegion = screen.getByTestId("profile-contents-scroll-region"); + expect(scrollRegion).toHaveClass("overflow-y-auto"); + expect(scrollRegion).toContainElement(screen.getByText(defaultProps.profilePath)); + }); - await appsSection(); - expect(screen.queryByText(/notepad-plus-plus/)).not.toBeInTheDocument(); - expect(screen.queryByText(defaultProps.profilePath)).not.toBeInTheDocument(); + it("shows inventory-completeness warnings but keeps diagnostics in details", async () => { + vi.mocked(useShowDetails).mockReturnValue(true); + const user = userEvent.setup(); + renderModal( + inspection({ + warnings: [ + { + code: "PARTIAL", + message: "Some settings could not be inventoried.", + impact: "inventory_incomplete", + }, + { code: "TRACE", message: "Technical trace.", impact: "diagnostic" }, + ], + }), + ); + expect( + await screen.findByText("Some settings could not be inventoried."), + ).toBeVisible(); + expect(screen.queryByText("Technical trace.")).not.toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "Configuration details" }), + ); + expect(screen.getByText("Technical trace.")).toBeVisible(); + }); - await user.click(screen.getByRole('button', { name: 'Configuration details' })); + it("states when the profile has no recorded capture date", async () => { + renderModal( + inspection({ profile: { ...inspection().profile, capturedAt: null } }), + ); - expect(screen.getByText('notepad-plus-plus, vlc')).toBeInTheDocument(); - expect(screen.getByText(defaultProps.profilePath)).toBeInTheDocument(); + expect(await screen.findByText("No capture date recorded")).toBeVisible(); }); - it('hides the details disclosure entirely when show-details is off', async () => { - await mockManifest(V1_MANIFEST); + it("surfaces a structured inspection failure instead of an empty inventory", async () => { + const onInspectProfile = vi + .fn() + .mockRejectedValue(new Error("MANIFEST_INVALID: missing apps")); + render( + , + ); - render(); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("This profile could not be read."); + expect(alert).toHaveTextContent("MANIFEST_INVALID: missing apps"); + }); - await appsSection(); + it("resets both tab queries when reopened", async () => { + const user = userEvent.setup(); + const onInspectProfile = vi.fn().mockResolvedValue(inspection()); + const { rerender } = render( + , + ); + await screen.findByRole("searchbox", { name: "Search apps" }); + await user.type( + screen.getByRole("searchbox", { name: "Search apps" }), + "obsidian", + ); + rerender( + , + ); + rerender( + , + ); expect( - screen.queryByRole('button', { name: 'Configuration details' }), - ).not.toBeInTheDocument(); + await screen.findByRole("searchbox", { name: "Search apps" }), + ).toHaveValue(""); }); - it('surfaces a read failure instead of an empty summary', async () => { - const { invoke } = await import('@/lib/tauri-bridge'); - vi.mocked(invoke).mockRejectedValue(new Error('File does not exist')); - - render(); + it("shows an update-required state without inspection on stale engines", async () => { + const onInspectProfile = vi.fn(); + render( + , + ); - const alert = await screen.findByRole('alert'); - expect(within(alert).getByText('This profile could not be read.')).toBeVisible(); - expect(within(alert).getByText('File does not exist')).toBeVisible(); + expect( + await screen.findByText( + "Update Endstate to inspect app settings accurately.", + ), + ).toBeVisible(); + expect(onInspectProfile).not.toHaveBeenCalled(); }); - it('closes when Close is pressed', async () => { - await mockManifest(V1_MANIFEST); - const onOpenChange = vi.fn(); - const user = userEvent.setup(); - - render(); + it("suppresses a stale inspection response after the profile changes", async () => { + let resolveFirst: (data: ProfileInspectionData) => void = () => undefined; + const first = new Promise((resolve) => { + resolveFirst = resolve; + }); + const onInspectProfile = vi + .fn() + .mockReturnValueOnce(first) + .mockResolvedValueOnce( + inspection({ profile: { ...inspection().profile, name: "second" } }), + ); + const { rerender } = render( + , + ); + rerender( + , + ); + await screen.findByText("2 apps"); + resolveFirst( + inspection({ profile: { ...inspection().profile, name: "stale" } }), + ); + await waitFor(() => + expect(screen.queryByText("stale")).not.toBeInTheDocument(), + ); + }); - await appsSection(); - // The footer button and the Dialog's own sr-only dismiss both read "Close"; - // the footer one renders first. - const [footerClose] = screen.getAllByRole('button', { name: 'Close' }); - await user.click(footerClose); + it("never renders loaded contents for a previous profile path", async () => { + let resolveCurrent: (data: ProfileInspectionData) => void = () => undefined; + const current = new Promise((resolve) => { resolveCurrent = resolve; }); + const old = inspection({ apps: [{ + id: "app:old:1", manifestAppId: "old", displayName: "Old profile app", + packageRefs: [], hasSettings: false, + }], settingsApps: [], summary: { appCount: 1, settingsRowCount: 0, verifiedSettingsAppCount: 0, unidentifiedSettingsRowCount: 0 } }); + const onInspectProfile = vi.fn().mockResolvedValueOnce(old).mockReturnValueOnce(current); + const { rerender } = render(); + expect(await screen.findByText("Old profile app")).toBeVisible(); + + rerender(); + expect(screen.queryByText("Old profile app")).not.toBeInTheDocument(); + resolveCurrent(inspection()); + expect( + await within(await screen.findByRole("tabpanel")).findByText("VLC media player"), + ).toBeVisible(); + }); - expect(onOpenChange).toHaveBeenCalledWith(false); + it("never renders an inspection error for a previous profile path", async () => { + let resolveCurrent: (data: ProfileInspectionData) => void = () => undefined; + const current = new Promise((resolve) => { resolveCurrent = resolve; }); + const onInspectProfile = vi + .fn() + .mockRejectedValueOnce(new Error("A profile could not be read")) + .mockReturnValueOnce(current); + const { rerender } = render(); + expect(await screen.findByRole("alert")).toHaveTextContent("A profile could not be read"); + + rerender(); + expect(screen.queryByText("A profile could not be read")).not.toBeInTheDocument(); + resolveCurrent(inspection()); + expect(await screen.findByRole("tabpanel")).toBeVisible(); }); }); diff --git a/src/components/app/intent/profile-contents-modal.tsx b/src/components/app/intent/profile-contents-modal.tsx index 34c3161f..98227e93 100644 --- a/src/components/app/intent/profile-contents-modal.tsx +++ b/src/components/app/intent/profile-contents-modal.tsx @@ -1,18 +1,7 @@ -/** - * "What's inside" — what a capture bundle actually contains, without an archiver. - * - * This answers "what am I about to apply?", not "what files are in this zip": - * when it was captured, how many apps and which ones, and which settings come - * with them. Everything shown is read from the manifest already extracted on - * disk; nothing is inferred. - * - * Module ids, capture ids and the file path stay behind "Configuration details" - * per openspec/specs/config-generation-presentation/spec.md and the jargon - * guardrail in docs/ux-guardrails.md. - */ - -import { useEffect, useState } from 'react'; -import { Loader2, Package, Settings2 } from 'lucide-react'; +/** Read-only profile inventory, authored by the engine's `profile inspect` command. */ + +import { useEffect, useId, useRef, useState } from "react"; +import { Loader2 } from "lucide-react"; import { Dialog, DialogContent, @@ -20,11 +9,17 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from '@/components/ui/dialog'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { DetailsDisclosure } from '@/components/ui/details-disclosure'; -import { loadProfileContents, type ProfileContents } from '@/lib/profile-contents'; +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DetailsDisclosure } from "@/components/ui/details-disclosure"; +import { Input } from "@/components/ui/input"; +import type { + ProfileInspectionData, + ProfileInspectionSettingsApp, +} from "@/types"; + +type ProfileContentsTab = "apps" | "settings"; interface ProfileContentsModalProps { open: boolean; @@ -33,8 +28,11 @@ interface ProfileContentsModalProps { profilePath: string; /** The name the profile card shows. */ profileDisplayName: string; + /** Advertised by the current capabilities envelope. */ + profileInspectionSupported?: boolean; + /** One-shot read-only engine inspection supplied by App. */ + onInspectProfile?: (manifestPath: string) => Promise; } - function formatCaptured(iso: string): string { const parsed = new Date(iso); if (Number.isNaN(parsed.getTime())) return iso; @@ -45,191 +43,421 @@ function pluralize(count: number, singular: string, plural: string): string { return `${count} ${count === 1 ? singular : plural}`; } +function defaultTab(contents: ProfileInspectionData): ProfileContentsTab { + return contents.apps.length === 0 && contents.settingsApps.length > 0 + ? "settings" + : "apps"; +} + +function rowMatches(query: string, values: string[]): boolean { + const needle = query.trim().toLowerCase(); + return ( + !needle || + values.some((value) => value.toLowerCase().includes(needle)) + ); +} + +function settingsAssociationCopy( + row: ProfileInspectionSettingsApp, +): string | null { + if (row.associationStatus === "not_in_profile") return "App not included"; + if ( + row.associationStatus === "ambiguous" || + row.associationStatus === "unresolved" + ) { + return "Association could not be identified."; + } + return null; +} + export function ProfileContentsModal({ open, onOpenChange, profilePath, profileDisplayName, + profileInspectionSupported = false, + onInspectProfile, }: ProfileContentsModalProps) { - const [contents, setContents] = useState(null); + const [contents, setContents] = useState<{ + profilePath: string; + data: ProfileInspectionData; + } | null>(null); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState<{ profilePath: string; message: string } | null>(null); + const [activeTab, setActiveTab] = useState("apps"); + const [appsQuery, setAppsQuery] = useState(""); + const [settingsQuery, setSettingsQuery] = useState(""); + const requestId = useRef(0); + const inspectRef = useRef(onInspectProfile); + const tabId = useId(); useEffect(() => { - if (!open || !profilePath) return; + inspectRef.current = onInspectProfile; + }, [onInspectProfile]); - let cancelled = false; - setLoading(true); - setError(null); + useEffect(() => { + const currentRequest = ++requestId.current; + setAppsQuery(""); + setSettingsQuery(""); setContents(null); + setError(null); + setActiveTab("apps"); - loadProfileContents(profilePath) + if (!open || !profilePath) { + setLoading(false); + return; + } + + if (!profileInspectionSupported || !inspectRef.current) { + setLoading(false); + return; + } + + setLoading(true); + void inspectRef + .current(profilePath) .then((result) => { - if (cancelled) return; - setContents(result); + if (requestId.current !== currentRequest) return; + setContents({ profilePath, data: result }); + setActiveTab(defaultTab(result)); }) .catch((err: unknown) => { - if (cancelled) return; - setError(err instanceof Error ? err.message : String(err)); + if (requestId.current !== currentRequest) return; + setError({ + profilePath, + message: err instanceof Error ? err.message : String(err), + }); }) .finally(() => { - if (!cancelled) setLoading(false); + if (requestId.current === currentRequest) setLoading(false); }); + }, [open, profilePath, profileInspectionSupported]); + + const chooseTab = (tab: ProfileContentsTab, focus = false) => { + setActiveTab(tab); + if (focus) { + document.getElementById(`${tabId}-${tab}-tab`)?.focus(); + } + }; - return () => { - cancelled = true; - }; - }, [open, profilePath]); + const handleTabKeyDown = (event: React.KeyboardEvent) => { + let nextTab: ProfileContentsTab | null = null; + if (event.key === "Home") nextTab = "apps"; + if (event.key === "End") nextTab = "settings"; + if (event.key === "ArrowLeft") nextTab = activeTab === "apps" ? "settings" : "apps"; + if (event.key === "ArrowRight") nextTab = activeTab === "apps" ? "settings" : "apps"; + if (!nextTab) return; + event.preventDefault(); + chooseTab(nextTab, true); + }; - const appCount = contents?.apps.length ?? 0; - const settingsCount = contents?.settingsModuleCount ?? 0; + // State updates happen after render. Keep a completed inspection tied to the + // profile that requested it so a changed path cannot paint prior contents. + const loadedContents = contents?.profilePath === profilePath ? contents.data : null; + const loadedError = error?.profilePath === profilePath ? error.message : null; + const appCount = loadedContents?.apps.length ?? 0; + const settingsCount = loadedContents?.settingsApps.length ?? 0; + const filteredApps = + loadedContents?.apps.filter((app) => + rowMatches(appsQuery, [app.displayName, ...app.packageRefs]), + ) ?? []; + const filteredSettings = + loadedContents?.settingsApps.filter((row) => + rowMatches(settingsQuery, [ + row.displayName, + ...row.packageRefs, + ...row.moduleIds, + ...row.candidateAppIds, + ]), + ) ?? []; + const activeQuery = activeTab === "apps" ? appsQuery : settingsQuery; return ( - + What's inside - {profileDisplayName || 'This profile'} - {contents?.captured - ? ` · captured ${formatCaptured(contents.captured)}` - : ''} + {profileDisplayName || loadedContents?.profile.name || "This profile"} + {loadedContents?.profile.capturedAt + ? ` · captured ${formatCaptured(loadedContents.profile.capturedAt)}` + : ""} + {!loading && !loadedError && !loadedContents && !profileInspectionSupported && ( +

+ Update Endstate to inspect app settings accurately. +

+ )} + {loading && ( -

+

)} - {error && ( + {loadedError && (
-

This profile could not be read.

-

{error}

+

+ This profile could not be read. +

+

{loadedError}

)} - {contents && !loading && !error && ( -
+ {loadedContents && !loading && !loadedError && ( +
- {pluralize(appCount, 'app', 'apps')} - {settingsCount > 0 && ( - - {pluralize(settingsCount, 'setting', 'settings')} - + + {pluralize(appCount, "app", "apps")} + + + {pluralize(settingsCount, "app setting", "app settings")} + + + Settings for{" "} + {pluralize( + loadedContents.summary.verifiedSettingsAppCount, + "app", + "apps", + )} + + {loadedContents.summary.unidentifiedSettingsRowCount > 0 && ( + + {pluralize( + loadedContents.summary.unidentifiedSettingsRowCount, + "unidentified app settings row", + "unidentified app settings rows", + )} + )} - {!contents.captured && ( + {!loadedContents.profile.capturedAt && ( No capture date recorded )}
-
-

-

- {appCount === 0 ? ( -

- This profile carries settings only — it installs no apps. + {loadedContents.warnings + .filter((warning) => warning.impact === "inventory_incomplete") + .map((warning) => ( +

+ {warning.message}

- ) : ( -
    - {contents.apps.map((label, index) => ( -
  • - {label} -
  • - ))} -
- )} -
+ ))} + + {appCount === 0 && settingsCount > 0 && ( +

+ This profile carries app settings but includes no apps. +

+ )} + {appCount > 0 && settingsCount === 0 && ( +

+ This profile includes apps but no app settings. +

+ )} + {appCount === 0 && settingsCount === 0 && ( +

+ This profile has no apps or app settings. +

+ )} + +
+ {( + [ + ["apps", `Apps (${appCount})`], + ["settings", `App settings (${settingsCount})`], + ] as const + ).map(([tab, label]) => ( + + ))} +
-
-

+ activeTab === "apps" + ? setAppsQuery(event.target.value) + : setSettingsQuery(event.target.value) + } + className="flex-shrink-0" + /> + +
+

- {settingsCount === 0 ? ( -

- This profile installs apps only — no settings are included. -

- ) : ( - <> -
    - {contents.settings.map((module, index) => ( + {filteredApps.length === 0 ? ( +

    + {appsQuery.trim() + ? `No apps match “${appsQuery.trim()}”.` + : "This profile includes no apps."} +

    + ) : ( +
      + {filteredApps.map((app) => (
    • - - {module.label} + + {app.displayName} - {module.entryCount > 0 && ( + {app.hasSettings && ( - {pluralize(module.entryCount, 'file', 'files')} + Settings included )}
    • ))}
    - {/* A module whose name could not be resolved is counted, never - named — its id belongs in Configuration details. State the - fact without guessing at a cause. */} - {contents.settings.length < settingsCount && ( -

    - {settingsCount - contents.settings.length === 1 - ? '1 more setting has no name in this profile.' - : `${settingsCount - contents.settings.length} more settings have no name in this profile.`} -

    - )} - - )} -
- - -
- {contents.profileName && ( -
-
Manifest name
-
{contents.profileName}
-
- )} - {contents.manifestVersion !== null && ( -
-
Manifest version
-
{contents.manifestVersion}
-
)} - {contents.captured && ( -
-
Captured
-
{contents.captured}
-
- )} - {contents.moduleIds.length > 0 && ( -
-
Module IDs
-
{contents.moduleIds.join(', ')}
-
+
+ + + +
+
+
Manifest version
+
+ {loadedContents.profile.manifestVersion} +
+
Path
-
{profilePath}
+
+ {loadedContents.profile.manifestPath} +
-
-
+ {loadedContents.apps.map((app) => + app.packageRefs.length > 0 ? ( +
+
{app.displayName}
+
{app.packageRefs.join(", ")}
+
+ ) : null, + )} + {loadedContents.settingsApps.map((row) => ( +
+
+ {row.displayName} +
+
+ {row.moduleIds.join(", ")} +
+ {row.packageRefs.length > 0 && ( +
+ {row.packageRefs.join(", ")} +
+ )} + {row.candidateAppIds.length > 0 && ( +
+ Candidates: {row.candidateAppIds.join(", ")} +
+ )} +
+ {pluralize( + row.capturedEntryCount, + "captured entry", + "captured entries", + )} +
+
+ ))} + {loadedContents.warnings + .filter((warning) => warning.impact === "diagnostic") + .map((warning) => ( +
+
+ {warning.code} +
+
{warning.message}
+
+ ))} + + +
)} diff --git a/src/components/app/intent/setup-flow-whats-inside.test.tsx b/src/components/app/intent/setup-flow-whats-inside.test.tsx index 972fbeda..9b470898 100644 --- a/src/components/app/intent/setup-flow-whats-inside.test.tsx +++ b/src/components/app/intent/setup-flow-whats-inside.test.tsx @@ -1,26 +1,61 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderWithProviders, screen, within } from '../../../test/test-utils'; -import userEvent from '@testing-library/user-event'; -import '@testing-library/jest-dom/vitest'; -import { SetupFlow } from './setup-flow'; -import type { DiscoveredProfile } from '../../../file-discovery'; - -vi.mock('@/lib/tauri-bridge', () => ({ - invoke: vi.fn(), -})); - -vi.mock('@/lib/use-show-details', () => ({ - useShowDetails: vi.fn(() => false), -})); +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderWithProviders, screen, within } from "../../../test/test-utils"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom/vitest"; +import { SetupFlow } from "./setup-flow"; +import type { DiscoveredProfile } from "../../../file-discovery"; +import type { ProfileInspectionData } from "@/types"; const profiles: DiscoveredProfile[] = [ - { name: 'work-laptop', path: 'C:\\Setups\\work-laptop\\manifest.jsonc', displayName: 'Work Laptop' }, + { + name: "work-laptop", + path: "C:\\Setups\\work-laptop\\manifest.jsonc", + displayName: "Work Laptop", + }, ]; +const contents: ProfileInspectionData = { + profile: { + name: "work-laptop", + capturedAt: null, + manifestVersion: 2, + manifestPath: profiles[0].path, + }, + summary: { + appCount: 1, + settingsRowCount: 1, + verifiedSettingsAppCount: 1, + unidentifiedSettingsRowCount: 0, + }, + apps: [ + { + id: "app:vlc:1", + manifestAppId: "vlc", + displayName: "VLC media player", + packageRefs: ["VideoLAN.VLC"], + hasSettings: true, + }, + ], + settingsApps: [ + { + id: "settings:vlc", + displayName: "VLC media player", + associationStatus: "included", + ownerId: "app:vlc:1", + appId: "app:vlc:1", + appIncluded: true, + packageRefs: ["VideoLAN.VLC"], + moduleIds: ["apps.vlc"], + candidateAppIds: ["app:vlc:1"], + capturedEntryCount: 1, + }, + ], + warnings: [], +}; + const baseProps = { profiles, onBack: vi.fn(), - onProfileSelect: vi.fn(), onOpenProfilesFolder: vi.fn(), onRefreshProfiles: vi.fn().mockResolvedValue(undefined), onFileDrop: vi.fn(), @@ -30,64 +65,92 @@ const baseProps = { liveAppEvents: [], onPreview: vi.fn(), onApply: vi.fn(), + profileInspectionSupported: true, + onInspectProfile: vi.fn().mockResolvedValue(contents), }; -const MANIFEST = JSON.stringify({ - version: 1, - name: 'work-laptop', - captured: '2026-07-18T12:00:00Z', - apps: [{ id: 'vlc', displayName: 'VLC media player' }], - restore: [{ type: 'copy', source: './configs/vlc/vlcrc' }], -}); - describe('SetupFlow — "What\'s inside"', () => { - beforeEach(async () => { - vi.clearAllMocks(); - const { invoke } = await import('@/lib/tauri-bridge'); - vi.mocked(invoke).mockResolvedValue(MANIFEST); - }); + beforeEach(() => vi.clearAllMocks()); it('offers a "What\'s inside" affordance on each profile card', () => { renderWithProviders(); - expect( - screen.getByRole('button', { name: "What's inside Work Laptop" }), + screen.getByRole("button", { name: "What's inside Work Laptop" }), ).toBeInTheDocument(); }); - it('opens the summary for that profile without selecting it', async () => { + it("inspects the exact manifest without selecting, previewing, detecting, or applying", async () => { const user = userEvent.setup(); - renderWithProviders(); - await user.click(screen.getByRole('button', { name: "What's inside Work Laptop" })); - - const dialog = await screen.findByRole('dialog'); - expect(within(dialog).getByText("What's inside")).toBeVisible(); - const apps = await screen.findByRole('region', { name: 'Apps' }); - expect(within(apps).getByText('VLC media player')).toBeVisible(); - - // Inspecting a bundle must never start a run. + await user.click( + screen.getByRole("button", { name: "What's inside Work Laptop" }), + ); + const dialog = await screen.findByRole("dialog"); + expect(within(within(dialog).getByRole("tabpanel")).getByText("VLC media player")).toBeVisible(); + expect(baseProps.onInspectProfile).toHaveBeenCalledWith(profiles[0].path); expect(baseProps.onPreview).not.toHaveBeenCalled(); + expect(baseProps.onApply).not.toHaveBeenCalled(); }); - it('reads the manifest of the card that was clicked', async () => { + it.each(["{Enter}", " "])( + "opens with %s without selecting the profile", + async (key) => { + const user = userEvent.setup(); + renderWithProviders(); + + screen + .getByRole("button", { name: "What's inside Work Laptop" }) + .focus(); + await user.keyboard(key); + + expect(await screen.findByRole("dialog")).toBeVisible(); + expect(baseProps.onInspectProfile).toHaveBeenCalledWith(profiles[0].path); + expect(baseProps.onPreview).not.toHaveBeenCalled(); + expect(baseProps.onApply).not.toHaveBeenCalled(); + }, + ); + + it("isolates only activation keys and lets shortcuts bubble normally", async () => { const user = userEvent.setup(); - const { invoke } = await import('@/lib/tauri-bridge'); - - renderWithProviders(); - - await user.click(screen.getByRole('button', { name: "What's inside Work Laptop" })); - await screen.findByRole('dialog'); + const onBubbledKey = vi.fn(); + renderWithProviders( +
onBubbledKey(event.key, event.ctrlKey)}> + +
, + ); + + const inspect = screen.getByRole("button", { name: "What's inside Work Laptop" }); + inspect.focus(); + await user.keyboard("{Control>}k{/Control}"); + expect(onBubbledKey).toHaveBeenCalledWith("k", true); + expect(baseProps.onPreview).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(vi.mocked(invoke)).toHaveBeenCalledWith('read_text_file', { - path: 'C:\\Setups\\work-laptop\\manifest.jsonc', - }); + await user.keyboard("{Enter}"); + expect(await screen.findByRole("dialog")).toBeVisible(); + expect(baseProps.onPreview).not.toHaveBeenCalled(); }); - it('keeps the summary closed until the affordance is used', () => { - renderWithProviders(); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + it("shows the update-required state without invoking inspection on an older engine", async () => { + const user = userEvent.setup(); + const onInspectProfile = vi.fn(); + renderWithProviders( + , + ); + + await user.click( + screen.getByRole("button", { name: "What's inside Work Laptop" }), + ); + expect( + await screen.findByText( + "Update Endstate to inspect app settings accurately.", + ), + ).toBeVisible(); + expect(onInspectProfile).not.toHaveBeenCalled(); }); }); diff --git a/src/components/app/intent/setup-flow.tsx b/src/components/app/intent/setup-flow.tsx index 1ef84d91..b7904bcd 100644 --- a/src/components/app/intent/setup-flow.tsx +++ b/src/components/app/intent/setup-flow.tsx @@ -30,6 +30,7 @@ import type { RestoreIntent, RestoreModuleRef, RestoreTargetMapping, + ProfileInspectionData, } from '@/types'; import type { EngineExecResult } from '@/lib/engine-exec'; import type { ConfigProgressEvent } from '@/lib/streaming-events'; @@ -376,6 +377,10 @@ export interface SetupFlowProps { * screen. Only rendered when the user is signed in + subscription is * active + the profile is not already in `cloudBackupIndex`. */ onPushProfileToCloud?: (profilePath: string, profileName: string) => void; + /** Current-engine support for the read-only `profile inspect` boundary. */ + profileInspectionSupported?: boolean; + /** Inspects a saved manifest without selecting or previewing it. */ + onInspectProfile?: (manifestPath: string) => Promise; } export function SetupFlow({ @@ -412,6 +417,8 @@ export function SetupFlow({ onOpenHostedBackup, onRestoreFromCloud, onPushProfileToCloud, + profileInspectionSupported = false, + onInspectProfile, }: SetupFlowProps) { const [refreshing, setRefreshing] = useState(false); // Profile whose "What's inside" summary is open. Inspection is read-only and @@ -1023,6 +1030,14 @@ export function SetupFlow({ e.stopPropagation(); setInspectedProfile(profile); }} + onKeyDown={(e) => { + // The card is a keyboard-selectable parent. Let the + // button keep its native Enter/Space activation while + // keeping only those keys out of the card's selection handler. + if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { + e.stopPropagation(); + } + }} aria-label={`What's inside ${profile.displayName || profile.name}`} className="gap-1 text-xs" data-testid={`profile-card-${profile.name}-whats-inside`} @@ -2206,6 +2221,8 @@ export function SetupFlow({ profileDisplayName={ inspectedProfile ? inspectedProfile.displayName || inspectedProfile.name : '' } + profileInspectionSupported={profileInspectionSupported} + onInspectProfile={onInspectProfile} /> ); diff --git a/src/e2e/mock-engine.conformance.test.ts b/src/e2e/mock-engine.conformance.test.ts index 9eb5d0ec..e84a0f8e 100644 --- a/src/e2e/mock-engine.conformance.test.ts +++ b/src/e2e/mock-engine.conformance.test.ts @@ -23,8 +23,95 @@ import { describe, it, expect } from 'vitest'; import golden from '../../tests/fixtures/apply-envelope.golden.json'; import restoreGolden from '../../tests/fixtures/restore-envelope.golden.json'; +import inspectionGolden from '../../tests/fixtures/profile-inspect-envelope.golden.json'; +import inspectionFixture from '../../e2e/fixtures/profile_inspect.fixture.json'; import { scenarioEnvelope } from './mock-engine'; +describe('mock engine conforms to the real profile inspection envelope', () => { + it('provides the committed long-profile inspection envelope', () => { + const envelope = scenarioEnvelope('profile_inspect_ok' as never) as Record; + expect(envelope).toEqual(inspectionFixture); + }); + + it('keeps the profile inspection wrapper and data shape aligned with the real-engine golden', () => { + const envelope = inspectionFixture as Record; + const data = envelope.data as Record; + const goldenData = inspectionGolden.data as Record; + + for (const key of Object.keys(inspectionGolden).filter((key) => key !== '_generatedBy')) { + expect(Object.prototype.hasOwnProperty.call(envelope, key), `mock profile envelope is missing ${key}`).toBe(true); + } + expect(Object.keys(data).sort()).toEqual(Object.keys(goldenData).sort()); + expect(envelope.schemaVersion).toMatch(/^1\./); + expect(envelope.command).toBe('profile'); + expect(envelope.success).toBe(true); + expect(envelope.error).toBeNull(); + expect(data.profile).toEqual(expect.objectContaining({ name: expect.any(String), capturedAt: expect.any(String) })); + expect(Object.keys((data.apps as Array>)[0]).sort()).toEqual( + Object.keys((goldenData.apps as Array>)[0]).sort(), + ); + expect(Object.keys((data.settingsApps as Array>)[0]).sort()).toEqual( + Object.keys((goldenData.settingsApps as Array>)[0]).sort(), + ); + }); + + it('preserves the long-profile summary, association matrix, identities, and searchable provenance', () => { + const data = inspectionFixture.data; + expect(data.apps).toHaveLength(72); + expect(data.settingsApps).toHaveLength(8); + expect(data.summary).toEqual({ + appCount: 72, + settingsRowCount: 8, + verifiedSettingsAppCount: 8, + unidentifiedSettingsRowCount: 0, + }); + expect(data.apps.filter((app) => app.hasSettings)).toHaveLength(7); + expect(new Set(data.apps.map((app) => app.id)).size).toBe(data.apps.length); + for (const app of data.apps) { + expect(app.id).toBe(`app:${app.manifestAppId.toLowerCase()}:1`); + } + expect(data.apps.map((app) => `${app.displayName.toLowerCase()}\u0000${app.id}`)).toEqual( + [...data.apps] + .sort((left, right) => { + const leftKey = `${left.displayName.toLowerCase()}\u0000${left.id}`; + const rightKey = `${right.displayName.toLowerCase()}\u0000${right.id}`; + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + }) + .map((app) => `${app.displayName.toLowerCase()}\u0000${app.id}`), + ); + expect(data.settingsApps.filter((row) => row.associationStatus === 'included')).toHaveLength(7); + expect(data.settingsApps.filter((row) => row.associationStatus === 'not_in_profile')).toHaveLength(1); + for (const row of data.settingsApps.filter((row) => row.associationStatus === 'included')) { + expect(row.id).toBe(`settings:${row.appId}`); + expect(row.ownerId).toBe(row.appId); + expect(row.appIncluded).toBe(true); + expect(row.candidateAppIds).toEqual([row.appId]); + expect(row.packageRefs).toEqual(expect.any(Array)); + expect(row.moduleIds).toEqual(expect.any(Array)); + } + expect(data.settingsApps.find((row) => row.associationStatus === 'not_in_profile')).toMatchObject({ + id: 'settings:package:vendor.absent', + ownerId: 'package:vendor.absent', + associationStatus: 'not_in_profile', + appId: null, + appIncluded: false, + candidateAppIds: [], + }); + expect(data.settingsApps.map((row) => `${row.displayName.toLowerCase()}\u0000${row.id}`)).toEqual( + [...data.settingsApps] + .sort((left, right) => { + const leftKey = `${left.displayName.toLowerCase()}\u0000${left.id}`; + const rightKey = `${right.displayName.toLowerCase()}\u0000${right.id}`; + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + }) + .map((row) => `${row.displayName.toLowerCase()}\u0000${row.id}`), + ); + expect(data.apps.find((app) => app.manifestAppId === 'cursor')?.packageRefs).toContain('com.endstate.hidden-package-ref'); + expect(data.settingsApps.find((row) => row.appId === 'app:cursor:1')?.moduleIds).toContain('apps.hidden-module-id'); + expect(data.warnings).toContainEqual(expect.objectContaining({ impact: 'inventory_incomplete' })); + }); +}); + describe('mock engine conforms to the real apply envelope', () => { const envelope = scenarioEnvelope('apply_ok_minimal') as Record; const data = envelope.data as Record; diff --git a/src/e2e/mock-engine.ts b/src/e2e/mock-engine.ts index 71020304..8a787571 100644 --- a/src/e2e/mock-engine.ts +++ b/src/e2e/mock-engine.ts @@ -13,6 +13,7 @@ import type { StreamEvent, RunResult } from '../streaming-runner'; import type { EngineExecResult } from '../lib/engine-exec'; import { parseEventsFile, replayEvents } from '../lib/event-replay'; import fixtureContent from '../../e2e/fixtures/capture_ok_realistic.events.jsonl?raw'; +import profileInspectionFixture from '../../e2e/fixtures/profile_inspect.fixture.json'; // Scenario types export type E2EScenario = @@ -24,6 +25,7 @@ export type E2EScenario = | 'capture_ok_minimal' | 'capture_ok_replay' | 'capabilities_ok' + | 'profile_inspect_ok' // Fault-injection scenarios (unhappy paths). These fire only on the real // (non-dry-run) apply invocation — init and preview stay healthy so the app // can boot and reach the Apply button. See getScenarioForCommand. @@ -285,13 +287,19 @@ const SCENARIOS: Record( onEvent?: (event: StreamEvent) => void, _options?: { onNdjsonEvent?: (event: any) => void } ): Promise> { + if (typeof window !== 'undefined') { + const commands = (window as any).__ENDSTATE_E2E_COMMANDS__; + if (Array.isArray(commands)) commands.push({ command, args: [...args] }); + } const scenario = getScenarioForCommand(command, args); const scenarioData = SCENARIOS[scenario]; diff --git a/src/lib/profile-contents.test.ts b/src/lib/profile-contents.test.ts index 33341a5a..02b89c92 100644 --- a/src/lib/profile-contents.test.ts +++ b/src/lib/profile-contents.test.ts @@ -1,310 +1,378 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { - appLabel, - loadProfileContents, - moduleIdFromRestoreSource, - shortModuleId, - summarizeProfileManifest, -} from './profile-contents'; -import type { ProfileManifest } from './jsonc-parse'; - -vi.mock('./tauri-bridge', () => ({ - invoke: vi.fn(), -})); - -describe('moduleIdFromRestoreSource', () => { - it('derives the module id from the bundle-relative configs path', () => { - expect(moduleIdFromRestoreSource('./configs/notepad-plus-plus/config.xml')).toBe( - 'notepad-plus-plus', - ); - expect(moduleIdFromRestoreSource('configs\\vlc\\vlcrc')).toBe('vlc'); - }); - - it('returns null when the source is not a module payload path', () => { - expect(moduleIdFromRestoreSource(undefined)).toBeNull(); - expect(moduleIdFromRestoreSource('./somewhere-else/file.txt')).toBeNull(); - // A bare configs/ carries no module segment. - expect(moduleIdFromRestoreSource('./configs/vlcrc')).toBeNull(); - }); -}); - -describe('shortModuleId', () => { - it('strips the apps namespace', () => { - expect(shortModuleId('apps.vlc')).toBe('vlc'); - expect(shortModuleId('vlc')).toBe('vlc'); - }); -}); - -describe('appLabel', () => { - it('prefers the captured display name over the package ref', () => { - expect( - appLabel({ id: '7zip-7zip', displayName: '7-Zip 25.01 (x64)', refs: { windows: '7zip.7zip' } }), - ).toBe('7-Zip 25.01 (x64)'); - }); - - it('falls back to the package ref, then to the manifest id', () => { - expect(appLabel({ id: 'vlc', refs: { windows: 'VideoLAN.VLC' } })).toBe('VideoLAN.VLC'); - expect(appLabel({ id: 'vlc' })).toBe('vlc'); - }); -}); - -describe('summarizeProfileManifest', () => { - it('counts and names apps and settings from a manifest v1 profile', () => { - const manifest: ProfileManifest = { - version: 1, - name: 'golden-fixture', - captured: '2026-07-18T12:00:00Z', + inspectProfileContents, + ProfileInspectionError, +} from "./profile-contents"; +import type { AppSettings } from "../settings"; + +vi.mock("./engine-exec", () => ({ runEndstateOnce: vi.fn() })); + +const SETTINGS = { + engineMode: "bundled", + customProfilesDirectory: "", +} as AppSettings; + +function inspectionEnvelope() { + return { + schemaVersion: "1.0", + cliVersion: "2.30.0", + command: "profile", + runId: "run-1", + timestampUtc: "2026-08-01T12:00:00Z", + success: true, + error: null, + data: { + profile: { + name: null, + capturedAt: null, + manifestVersion: 2, + manifestPath: "C:\\Profiles\\example\\manifest.jsonc", + }, + summary: { + appCount: 2, + settingsRowCount: 4, + verifiedSettingsAppCount: 2, + unidentifiedSettingsRowCount: 2, + }, apps: [ - { id: 'vlc', displayName: 'VLC media player', refs: { windows: 'VideoLAN.VLC' } }, { - id: 'notepad-plus-plus', - displayName: 'Notepad++', - refs: { windows: 'Notepad++.Notepad++' }, + id: "app:one:1", + manifestAppId: "one", + displayName: "One", + packageRefs: ["Example.One"], + hasSettings: true, + }, + { + id: "app:two:1", + manifestAppId: "two", + displayName: "Two", + packageRefs: [], + hasSettings: false, }, ], - restore: [ - { type: 'copy', source: './configs/vlc/vlcrc', target: '%APPDATA%\\vlc\\vlcrc' }, + settingsApps: [ + { + id: "settings:app:one:1", + displayName: "One settings", + associationStatus: "included", + ownerId: "app:one:1", + appId: "app:one:1", + appIncluded: true, + packageRefs: ["Example.One"], + moduleIds: ["one"], + candidateAppIds: ["app:one:1"], + capturedEntryCount: 3, + }, + { + id: "settings:absent", + displayName: "Absent settings", + associationStatus: "not_in_profile", + ownerId: "owner:absent", + appId: null, + appIncluded: false, + packageRefs: ["Example.Absent"], + moduleIds: ["absent"], + candidateAppIds: [], + capturedEntryCount: 0, + }, { - type: 'copy', - source: './configs/notepad-plus-plus/config.xml', - target: '%APPDATA%\\Notepad++\\config.xml', + id: "settings:module:ambiguous", + displayName: "Ambiguous settings", + associationStatus: "ambiguous", + ownerId: null, + appId: null, + appIncluded: false, + packageRefs: ["Example.One", "Example.Two"], + moduleIds: ["ambiguous"], + candidateAppIds: ["app:one:1", "app:two:1"], + capturedEntryCount: 1, }, { - type: 'copy', - source: './configs/notepad-plus-plus/shortcuts.xml', - target: '%APPDATA%\\Notepad++\\shortcuts.xml', + id: "settings:module:unresolved", + displayName: "Unresolved settings", + associationStatus: "unresolved", + ownerId: null, + appId: null, + appIncluded: false, + packageRefs: [], + moduleIds: ["unresolved"], + candidateAppIds: [], + capturedEntryCount: 1, }, ], - }; - - const contents = summarizeProfileManifest(manifest); - - expect(contents.apps).toEqual(['VLC media player', 'Notepad++']); - expect(contents.settingsModuleCount).toBe(2); - expect(contents.settingsEntryCount).toBe(3); - expect(contents.settings).toEqual([ - { label: 'VLC media player', entryCount: 1 }, - { label: 'Notepad++', entryCount: 2 }, - ]); - expect(contents.captured).toBe('2026-07-18T12:00:00Z'); - expect(contents.manifestVersion).toBe(1); - }); - - it('reports no settings for an install-only profile', () => { - const contents = summarizeProfileManifest({ - version: 1, - name: 'apps-only', - apps: [{ id: 'jq', refs: { windows: 'jqlang.jq' } }], - }); - - expect(contents.apps).toEqual(['jqlang.jq']); - expect(contents.settings).toEqual([]); - expect(contents.settingsModuleCount).toBe(0); - expect(contents.settingsEntryCount).toBe(0); - expect(contents.moduleIds).toEqual([]); - expect(contents.captured).toBeNull(); + warnings: [ + { code: "DIAGNOSTIC", message: "Useful detail", impact: "diagnostic" }, + ], + }, + }; +} + +async function mockInspection(envelope = inspectionEnvelope()) { + const { runEndstateOnce } = await import("./engine-exec"); + vi.mocked(runEndstateOnce).mockResolvedValue({ + success: true, + envelope, + } as never); +} + +describe("inspectProfileContents", () => { + beforeEach(async () => { + vi.clearAllMocks(); + await mockInspection(); }); - it('counts manifest v2 config captures by their payload file count', () => { - const contents = summarizeProfileManifest( - { - version: 2, - name: 'capture-v2', - apps: [], - configCaptures: [ - { - captureId: 'fixture-stable-preferences-installed', - moduleId: 'apps.fixture-stable', - configSetId: 'preferences', - captureModule: { snapshotPath: 'provenance/modules/apps.fixture-stable.json' }, - payloadManifest: [{ relativePath: 'settings.json' }, { relativePath: 'keys.json' }], - }, - ], - }, - { 'apps.fixture-stable': 'Fixture Stable' }, + it("calls only profile inspect with the supplied manifest path and preserves engine order", async () => { + const contents = await inspectProfileContents( + SETTINGS, + "C:\\Profiles\\example\\manifest.jsonc", ); + const { runEndstateOnce } = await import("./engine-exec"); - expect(contents.settings).toEqual([{ label: 'Fixture Stable', entryCount: 2 }]); - expect(contents.settingsModuleCount).toBe(1); - expect(contents.settingsEntryCount).toBe(2); + expect(runEndstateOnce).toHaveBeenCalledWith(SETTINGS, "profile", [ + "inspect", + "C:\\Profiles\\example\\manifest.jsonc", + ]); + expect(contents.apps.map((app) => app.displayName)).toEqual(["One", "Two"]); }); - it('ignores the flat restore list when config captures describe the same payload', () => { - const contents = summarizeProfileManifest( - { - version: 2, - apps: [], - configCaptures: [ - { - moduleId: 'apps.fixture-stable', - payloadManifest: [{ relativePath: 'settings.json' }], - }, - ], - restore: [{ type: 'copy', source: './configs/fixture-stable/settings.json' }], - }, - { 'apps.fixture-stable': 'Fixture Stable' }, - ); + it("fails closed when the response describes a different manifest", async () => { + const envelope = inspectionEnvelope(); + envelope.data.profile.manifestPath = "C:\\Profiles\\other\\manifest.jsonc"; + await mockInspection(envelope); - expect(contents.settingsEntryCount).toBe(1); - expect(contents.settingsModuleCount).toBe(1); + await expect( + inspectProfileContents(SETTINGS, "C:\\Profiles\\example\\manifest.jsonc"), + ).rejects.toThrow(/incompatible profile inspection response/i); }); - it('never uses a raw module id as a settings label', () => { - const contents = summarizeProfileManifest({ - version: 1, - // No app entry owns this module, so nothing friendly resolves. - apps: [], - restore: [{ type: 'copy', source: './configs/photoshop/prefs.psp' }], + it("accepts Windows-equivalent manifest separators and casing", async () => { + await expect( + inspectProfileContents(SETTINGS, "c:/profiles/example/manifest.jsonc"), + ).resolves.toMatchObject({ + profile: { manifestPath: "C:\\Profiles\\example\\manifest.jsonc" }, }); - - expect(contents.settings).toEqual([]); - // Still counted — the profile does carry it. - expect(contents.settingsModuleCount).toBe(1); - // And the id survives for the details disclosure. - expect(contents.moduleIds).toEqual(['photoshop']); }); - it('rejects a snapshot display name that merely echoes the module id', () => { - const contents = summarizeProfileManifest( - { - version: 2, - apps: [], - configCaptures: [ - { moduleId: 'apps.photoshop', payloadManifest: [{ relativePath: 'prefs.psp' }] }, - ], - }, - { 'apps.photoshop': 'apps.photoshop' }, - ); + it("does not collapse a UNC path into a drive-rooted path", async () => { + const envelope = inspectionEnvelope(); + envelope.data.profile.manifestPath = "\\\\server\\share\\manifest.jsonc"; + await mockInspection(envelope); - expect(contents.settings).toEqual([]); - expect(contents.settingsModuleCount).toBe(1); + await expect( + inspectProfileContents(SETTINGS, "\\server\\share\\manifest.jsonc"), + ).rejects.toThrow(/incompatible profile inspection response/i); }); - it('labels a settings module from the capture package ref when no name resolves', () => { - const contents = summarizeProfileManifest({ - version: 2, - apps: [], - configCaptures: [ - { - moduleId: 'apps.fixture-stable', - sourceInstance: { evidence: { ref: 'Fixture.Stable' } }, - payloadManifest: [{ relativePath: 'settings.json' }], - }, - ], - }); - - expect(contents.settings).toEqual([{ label: 'Fixture.Stable', entryCount: 1 }]); - }); - - it('tolerates a manifest with no apps or settings at all', () => { - const contents = summarizeProfileManifest({}); - - expect(contents.apps).toEqual([]); - expect(contents.settings).toEqual([]); - expect(contents.profileName).toBe(''); - expect(contents.manifestVersion).toBeNull(); + it.each([ + [ + "non-1.x schema", + (envelope: any) => { + envelope.schemaVersion = "2.0"; + }, + ], + [ + "missing cli version", + (envelope: any) => { + delete envelope.cliVersion; + }, + ], + [ + "wrong command", + (envelope: any) => { + envelope.command = "inspect"; + }, + ], + [ + "failed success flag", + (envelope: any) => { + envelope.success = false; + }, + ], + [ + "non-null success error", + (envelope: any) => { + envelope.error = { code: "BAD" }; + }, + ], + [ + "missing timestamp", + (envelope: any) => { + delete envelope.timestampUtc; + }, + ], + [ + "missing apps", + (envelope: any) => { + delete envelope.data.apps; + }, + ], + [ + "null settingsApps", + (envelope: any) => { + envelope.data.settingsApps = null; + }, + ], + [ + "missing profile path", + (envelope: any) => { + delete envelope.data.profile.manifestPath; + }, + ], + [ + "unknown association status", + (envelope: any) => { + envelope.data.settingsApps[0].associationStatus = "maybe"; + }, + ], + [ + "negative entry count", + (envelope: any) => { + envelope.data.settingsApps[0].capturedEntryCount = -1; + }, + ], + [ + "unknown warning impact", + (envelope: any) => { + envelope.data.warnings[0].impact = "unknown"; + }, + ], + ])("fails closed on a %s response", async (_name, mutate) => { + const envelope = inspectionEnvelope(); + mutate(envelope); + await mockInspection(envelope); + await expect( + inspectProfileContents(SETTINGS, "C:\\Profiles\\example\\manifest.jsonc"), + ).rejects.toThrow(/incompatible profile inspection response/i); }); -}); -describe('loadProfileContents', () => { - beforeEach(() => { - vi.clearAllMocks(); + it.each([ + [ + "included lacks its sole owner", + (row: any) => { + row.ownerId = null; + }, + ], + [ + "included is not included", + (row: any) => { + row.appIncluded = false; + }, + ], + [ + "included has a missing candidate", + (row: any) => { + row.candidateAppIds = []; + }, + ], + [ + "not-in-profile has an app id", + (row: any) => { + row.appId = "app:one:1"; + }, + ], + [ + "ambiguous lacks two unique candidates", + (row: any) => { + row.candidateAppIds = ["app:one:1"]; + }, + ], + [ + "unresolved has candidates", + (row: any) => { + row.candidateAppIds = ["app:one:1"]; + }, + ], + ])("rejects invalid association semantics when %s", async (_name, mutate) => { + const envelope = inspectionEnvelope(); + const row = _name.startsWith("included") + ? envelope.data.settingsApps[0] + : _name.startsWith("not-in-profile") + ? envelope.data.settingsApps[1] + : _name.startsWith("ambiguous") + ? envelope.data.settingsApps[2] + : envelope.data.settingsApps[3]; + mutate(row); + await mockInspection(envelope); + await expect( + inspectProfileContents(SETTINGS, "C:\\Profiles\\example\\manifest.jsonc"), + ).rejects.toThrow(/incompatible profile inspection response/i); }); - it('parses a JSONC manifest with comments', async () => { - const { invoke } = await import('./tauri-bridge'); - vi.mocked(invoke).mockResolvedValue(`{ - // A captured profile. - "version": 1, - "name": "my-desktop", - "apps": [{ "id": "vlc", "displayName": "VLC media player" }] - }`); - - const contents = await loadProfileContents('C:\\Setups\\my-desktop\\manifest.jsonc'); - - expect(contents.apps).toEqual(['VLC media player']); - expect(contents.profileName).toBe('my-desktop'); + it.each([ + [ + "summary counts disagree", + (envelope: any) => { + envelope.data.summary.appCount = 1; + }, + ], + [ + "an included row points to an unknown app", + (envelope: any) => { + envelope.data.settingsApps[0].appId = "missing"; + envelope.data.settingsApps[0].ownerId = "missing"; + envelope.data.settingsApps[0].candidateAppIds = ["missing"]; + }, + ], + [ + "an app disagrees about settings", + (envelope: any) => { + envelope.data.apps[0].hasSettings = false; + }, + ], + ])("rejects when %s", async (_name, mutate) => { + const envelope = inspectionEnvelope(); + mutate(envelope); + await mockInspection(envelope); + await expect( + inspectProfileContents(SETTINGS, "C:\\Profiles\\example\\manifest.jsonc"), + ).rejects.toThrow(/incompatible profile inspection response/i); }); - it('reads module snapshots next to the manifest for real display names', async () => { - const { invoke } = await import('./tauri-bridge'); - vi.mocked(invoke).mockImplementation(async (_cmd: string, args?: Record) => { - const path = args?.path as string; - if (path.endsWith('manifest.jsonc')) { - return JSON.stringify({ - version: 2, - apps: [], - configCaptures: [ - { - moduleId: 'apps.fixture-stable', - captureModule: { snapshotPath: 'provenance/modules/apps.fixture-stable.json' }, - payloadManifest: [{ relativePath: 'settings.json' }], - }, - ], - }); - } - return JSON.stringify({ id: 'apps.fixture-stable', displayName: 'Fixture Stable' }); - }); - - const contents = await loadProfileContents('C:\\Setups\\capture-v2\\manifest.jsonc'); - - expect(contents.settings).toEqual([{ label: 'Fixture Stable', entryCount: 1 }]); - expect(vi.mocked(invoke)).toHaveBeenCalledWith('read_text_file', { - path: 'C:\\Setups\\capture-v2\\provenance\\modules\\apps.fixture-stable.json', - }); + it.each([ + [ + "settings row IDs are duplicated", + (envelope: any) => { + envelope.data.settingsApps[3].id = envelope.data.settingsApps[2].id; + }, + ], + [ + "verified owner IDs are duplicated", + (envelope: any) => { + envelope.data.settingsApps[1].ownerId = envelope.data.settingsApps[0].ownerId; + }, + ], + ])("rejects when %s", async (_name, mutate) => { + const envelope = inspectionEnvelope(); + mutate(envelope); + await mockInspection(envelope); + + await expect( + inspectProfileContents(SETTINGS, "C:\\Profiles\\example\\manifest.jsonc"), + ).rejects.toThrow(/incompatible profile inspection response/i); }); - it('summarizes the profile even when a module snapshot cannot be read', async () => { - const { invoke } = await import('./tauri-bridge'); - vi.mocked(invoke).mockImplementation(async (_cmd: string, args?: Record) => { - const path = args?.path as string; - if (path.endsWith('manifest.jsonc')) { - return JSON.stringify({ - version: 2, - apps: [{ id: 'fixture-stable', displayName: 'Fixture Stable' }], - configCaptures: [ - { - moduleId: 'apps.fixture-stable', - captureModule: { snapshotPath: 'provenance/modules/apps.fixture-stable.json' }, - payloadManifest: [{ relativePath: 'settings.json' }], - }, - ], - }); - } - throw new Error('File does not exist'); + it("surfaces the engine structured failure without attempting a local fallback", async () => { + const { runEndstateOnce } = await import("./engine-exec"); + vi.mocked(runEndstateOnce).mockResolvedValue({ + success: false, + error: { kind: "command_failed", message: "Manifest was invalid" }, + envelope: { + error: { + code: "MANIFEST_VALIDATION_ERROR", + message: "Manifest was invalid", + }, + }, + } as never); + + await expect( + inspectProfileContents(SETTINGS, "C:\\Profiles\\example\\manifest.jsonc"), + ).rejects.toMatchObject({ + name: "ProfileInspectionError", + code: "MANIFEST_VALIDATION_ERROR", + message: "Manifest was invalid", }); - - const contents = await loadProfileContents('C:\\Setups\\capture-v2\\manifest.jsonc'); - - // Falls back to the owning app's display name rather than failing. - expect(contents.settings).toEqual([{ label: 'Fixture Stable', entryCount: 1 }]); - }); - - it('does not read a snapshot path that escapes the provenance directory', async () => { - const { invoke } = await import('./tauri-bridge'); - vi.mocked(invoke).mockResolvedValue( - JSON.stringify({ - version: 2, - apps: [], - configCaptures: [ - { - moduleId: 'apps.evil', - captureModule: { snapshotPath: '../../../../etc/passwd' }, - payloadManifest: [], - }, - ], - }), - ); - - await loadProfileContents('C:\\Setups\\evil\\manifest.jsonc'); - - expect(vi.mocked(invoke)).toHaveBeenCalledTimes(1); - }); - - it('propagates a manifest that cannot be parsed', async () => { - const { invoke } = await import('./tauri-bridge'); - vi.mocked(invoke).mockResolvedValue('not json at all'); - - await expect(loadProfileContents('C:\\Setups\\broken\\manifest.jsonc')).rejects.toThrow(); + await expect( + inspectProfileContents(SETTINGS, "C:\\Profiles\\example\\manifest.jsonc"), + ).rejects.toBeInstanceOf(ProfileInspectionError); }); }); diff --git a/src/lib/profile-contents.ts b/src/lib/profile-contents.ts index d1449b63..4d7b5a50 100644 --- a/src/lib/profile-contents.ts +++ b/src/lib/profile-contents.ts @@ -1,280 +1,249 @@ -/** - * "What's inside" — a human summary of what a capture bundle contains. - * - * Profiles reachable from the Set up flow are already imported and extracted, so - * `manifest.jsonc` and its sibling `provenance/modules/*.json` snapshots are - * plain files on disk. This module reads those and nothing else: no engine - * spawn, no zip handling. - * - * Raw module ids are deliberately never used as a label. An id that cannot be - * resolved to a friendly name yields a counted-but-unnamed module rather than a - * row reading `apps.notepad-plus-plus`. The ids travel separately in - * `moduleIds`, for the "Configuration details" disclosure only — see - * openspec/specs/config-generation-presentation/spec.md and the guard in - * `moduleDisplayNameMap` (setup-flow.tsx) this mirrors. - */ - -import { invoke } from './tauri-bridge'; -import { - parseJsonc, - type ProfileApp, - type ProfileManifest, - type ProfileModuleSnapshot, -} from './jsonc-parse'; - -const APPS_PREFIX = 'apps.'; - -/** One settings module the profile carries, named and counted. */ -export interface ProfileSettingsModule { - /** Friendly label. Never a raw module id. */ - label: string; - /** Config files (v2) or restore entries (v1) this module contributes. */ - entryCount: number; -} - -export interface ProfileContents { - /** Manifest-declared profile name, empty when the manifest omits it. */ - profileName: string; - /** ISO 8601 capture timestamp, or null when the manifest omits it. */ - captured: string | null; - /** App labels, in manifest order. */ - apps: string[]; - /** Named settings modules. Shorter than `settingsModuleCount` if any id was unresolvable. */ - settings: ProfileSettingsModule[]; - /** Distinct settings modules the profile carries, including unnamed ones. */ - settingsModuleCount: number; - /** Total config files / restore entries across all modules. */ - settingsEntryCount: number; - /** Raw module ids, sorted. Disclosure-only — never render these as labels. */ - moduleIds: string[]; - manifestVersion: number | null; -} - -/** Strip the `apps.` namespace so `apps.vlc` and `vlc` resolve to one another. */ -export function shortModuleId(moduleId: string): string { - return moduleId.startsWith(APPS_PREFIX) ? moduleId.slice(APPS_PREFIX.length) : moduleId; +/** Read-only bridge for the engine-owned `profile inspect` contract. */ + +import { z } from "zod"; +import { runEndstateOnce } from "./engine-exec"; +import type { AppSettings } from "../settings"; +import type { EndstateEnvelope, ProfileInspectionData } from "../types"; + +const nonNegativeInteger = z.number().int().nonnegative(); +const associationStatus = z.enum([ + "included", + "not_in_profile", + "ambiguous", + "unresolved", +]); + +const inspectionAppSchema = z + .object({ + id: z.string(), + manifestAppId: z.string(), + displayName: z.string(), + packageRefs: z.array(z.string()), + hasSettings: z.boolean(), + }) + .passthrough(); + +const inspectionSettingsAppSchema = z + .object({ + id: z.string(), + displayName: z.string(), + associationStatus, + ownerId: z.string().nullable(), + appId: z.string().nullable(), + appIncluded: z.boolean(), + packageRefs: z.array(z.string()), + moduleIds: z.array(z.string()), + candidateAppIds: z.array(z.string()), + capturedEntryCount: nonNegativeInteger, + }) + .passthrough(); + +const inspectionDataSchema = z + .object({ + profile: z + .object({ + name: z.string().nullable(), + capturedAt: z.string().nullable(), + manifestVersion: z.number().int(), + manifestPath: z.string(), + }) + .passthrough(), + summary: z + .object({ + appCount: nonNegativeInteger, + settingsRowCount: nonNegativeInteger, + verifiedSettingsAppCount: nonNegativeInteger, + unidentifiedSettingsRowCount: nonNegativeInteger, + }) + .passthrough(), + apps: z.array(inspectionAppSchema), + settingsApps: z.array(inspectionSettingsAppSchema), + warnings: z.array( + z + .object({ + code: z.string(), + message: z.string(), + impact: z.enum(["diagnostic", "inventory_incomplete"]), + }) + .passthrough(), + ), + }) + .passthrough(); + +const inspectionEnvelopeSchema = z + .object({ + schemaVersion: z.string().regex(/^1\.\d+$/), + cliVersion: z.string().min(1), + command: z.literal("profile"), + runId: z.string().min(1), + timestampUtc: z.string().min(1), + success: z.literal(true), + data: inspectionDataSchema, + error: z.null(), + }) + .passthrough(); + +/** Structured error from the read-only profile inspection command. */ +export class ProfileInspectionError extends Error { + readonly code: string; + readonly remediation?: string; + readonly detail?: Record; + + constructor(args: { + code: string; + message: string; + remediation?: string; + detail?: Record; + }) { + super(args.message); + this.name = "ProfileInspectionError"; + this.code = args.code; + this.remediation = args.remediation; + this.detail = args.detail; + } } - -/** - * Derive a module id from a v1 restore source path. - * `./configs/notepad-plus-plus/config.xml` → `notepad-plus-plus`. - */ -export function moduleIdFromRestoreSource(source: string | undefined): string | null { - if (!source) return null; - const match = /(?:^|[\\/])configs[\\/]([^\\/]+)[\\/]/.exec(source); - return match ? match[1] : null; +function incompatibleInspectionResponse(): ProfileInspectionError { + return new ProfileInspectionError({ + code: "INCOMPATIBLE_PROFILE_INSPECTION_RESPONSE", + message: + "Incompatible profile inspection response. Please update Endstate and try again.", + }); } -/** - * The friendly label for an app row: the captured display name, else the - * package ref, else the manifest id. This is the same chain `ViewAppsModal` - * uses, plus the `displayName` capture actually writes. - */ -export function appLabel(app: ProfileApp): string { - const display = app.displayName?.trim() || app.name?.trim(); - if (display) return display; - const ref = app.refs?.windows?.trim(); - if (ref) return ref; - return app.id; +function sameWindowsPath(left: string, right: string): boolean { + const normalize = (path: string) => { + const windowsPath = path.replace(/\//g, "\\"); + const isUnc = windowsPath.startsWith("\\\\"); + const compactPath = windowsPath.replace(/\\{2,}/g, "\\"); + return `${isUnc ? "\\" : ""}${compactPath}`.toLowerCase(); + }; + return normalize(left) === normalize(right); } -/** - * Index every alias of a module id to a friendly name. - * - * A candidate equal to the id it describes is provenance echoed back, not copy, - * and is rejected — the same rule `moduleDisplayNameMap` applies to engine - * `RestoreModuleRef`s. - */ -function indexModuleName( - index: Map, - moduleId: string | undefined, - candidate: string | undefined, +function validateInspectionRelations( + data: ProfileInspectionData, + manifestPath: string, ): void { - const id = moduleId?.trim(); - const label = candidate?.trim(); - if (!id || !label) return; - - const short = shortModuleId(id); - const qualified = `${APPS_PREFIX}${short}`; - if (label === id || label === short || label === qualified) return; - - index.set(id, label); - index.set(short, label); - index.set(qualified, label); -} - -/** - * Build the module-id → friendly-name index. - * - * Module snapshots win: they carry the catalog's own display name. The app list - * is the fallback, and works because a v1 module id is the app id that owns it. - */ -function buildModuleLabelIndex( - manifest: ProfileManifest, - snapshotNames: Readonly>, -): Map { - const index = new Map(); - - for (const app of manifest.apps ?? []) { - if (!app?.id) continue; - indexModuleName(index, app.id, appLabel(app)); - } - for (const [moduleId, displayName] of Object.entries(snapshotNames)) { - indexModuleName(index, moduleId, displayName); + if (!sameWindowsPath(data.profile.manifestPath, manifestPath)) { + throw incompatibleInspectionResponse(); } - return index; -} + const appsById = new Map(data.apps.map((app) => [app.id, app])); + if (appsById.size !== data.apps.length) + throw incompatibleInspectionResponse(); -interface ModuleTally { - moduleId: string; - entryCount: number; - /** Package ref from capture evidence — a usable label when nothing else resolves. */ - evidenceRef?: string; -} + const settingsRowIds = new Set(); + const includedAppIds = new Set(); + const verifiedOwnerIds = new Set(); + let unidentifiedSettingsRowCount = 0; -/** Aggregate the manifest's settings lane into per-module tallies, in first-seen order. */ -function tallySettingsModules(manifest: ProfileManifest): ModuleTally[] { - const tallies: ModuleTally[] = []; - const byId = new Map(); + for (const row of data.settingsApps) { + if (settingsRowIds.has(row.id)) throw incompatibleInspectionResponse(); + settingsRowIds.add(row.id); - const add = (moduleId: string | undefined, entryCount: number, evidenceRef?: string) => { - const id = moduleId?.trim(); - if (!id) return; - let tally = byId.get(id); - if (!tally) { - tally = { moduleId: id, entryCount: 0 }; - byId.set(id, tally); - tallies.push(tally); + if ( + !row.candidateAppIds.every((candidateId) => appsById.has(candidateId)) + ) { + throw incompatibleInspectionResponse(); } - tally.entryCount += entryCount; - if (!tally.evidenceRef && evidenceRef?.trim()) tally.evidenceRef = evidenceRef.trim(); - }; - - // v2 is authoritative when present: a manifest carrying configCaptures - // describes the same payload through `legacyConfigLanes`, so counting the - // flat `restore` list too would double-report it. - const captures = manifest.configCaptures ?? []; - if (captures.length > 0) { - for (const capture of captures) { - add( - capture?.moduleId, - capture?.payloadManifest?.length ?? 0, - capture?.sourceInstance?.evidence?.ref, - ); + switch (row.associationStatus) { + case "included": + if ( + !row.ownerId || + !row.appId || + row.ownerId !== row.appId || + !row.appIncluded || + row.candidateAppIds.length !== 1 || + row.candidateAppIds[0] !== row.appId || + !appsById.has(row.appId) + ) { + throw incompatibleInspectionResponse(); + } + includedAppIds.add(row.appId); + if (verifiedOwnerIds.has(row.ownerId)) throw incompatibleInspectionResponse(); + verifiedOwnerIds.add(row.ownerId); + break; + case "not_in_profile": + if ( + !row.ownerId || + row.appId !== null || + row.appIncluded || + row.candidateAppIds.length !== 0 + ) { + throw incompatibleInspectionResponse(); + } + if (verifiedOwnerIds.has(row.ownerId)) throw incompatibleInspectionResponse(); + verifiedOwnerIds.add(row.ownerId); + break; + case "ambiguous": + if ( + row.ownerId !== null || + row.appId !== null || + row.appIncluded || + row.candidateAppIds.length < 2 || + new Set(row.candidateAppIds).size !== row.candidateAppIds.length + ) { + throw incompatibleInspectionResponse(); + } + unidentifiedSettingsRowCount += 1; + break; + case "unresolved": + if ( + row.ownerId !== null || + row.appId !== null || + row.appIncluded || + row.candidateAppIds.length !== 0 + ) { + throw incompatibleInspectionResponse(); + } + unidentifiedSettingsRowCount += 1; + break; } - return tallies; } - for (const entry of manifest.restore ?? []) { - add(entry?.fromModule ?? moduleIdFromRestoreSource(entry?.source) ?? undefined, 1); + for (const app of data.apps) { + if (app.hasSettings !== includedAppIds.has(app.id)) + throw incompatibleInspectionResponse(); } - return tallies; -} - -/** - * Summarize a parsed manifest. - * - * @param snapshotNames module id → display name, read from the bundle's module - * snapshots. Optional: without it, labels fall back to the app list. - */ -export function summarizeProfileManifest( - manifest: ProfileManifest, - snapshotNames: Readonly> = {}, -): ProfileContents { - const labelIndex = buildModuleLabelIndex(manifest, snapshotNames); - const tallies = tallySettingsModules(manifest); - - const settings: ProfileSettingsModule[] = []; - for (const tally of tallies) { - const label = - labelIndex.get(tally.moduleId) ?? - labelIndex.get(shortModuleId(tally.moduleId)) ?? - tally.evidenceRef; - // No friendly name resolved: the module still counts, but showing its id - // here would leak provenance into the distilled summary. - if (!label) continue; - settings.push({ label, entryCount: tally.entryCount }); + if ( + data.summary.appCount !== data.apps.length || + data.summary.settingsRowCount !== data.settingsApps.length || + data.summary.verifiedSettingsAppCount !== verifiedOwnerIds.size || + data.summary.unidentifiedSettingsRowCount !== unidentifiedSettingsRowCount + ) { + throw incompatibleInspectionResponse(); } - - const captured = manifest.captured?.trim(); - - return { - profileName: manifest.name?.trim() ?? '', - captured: captured || null, - apps: (manifest.apps ?? []).filter((app) => app?.id).map(appLabel), - settings, - settingsModuleCount: tallies.length, - settingsEntryCount: tallies.reduce((total, tally) => total + tally.entryCount, 0), - moduleIds: tallies.map((tally) => tally.moduleId).sort(), - manifestVersion: typeof manifest.version === 'number' ? manifest.version : null, - }; } -/** The directory holding the manifest — where the bundle's payload sits. */ -function bundleDirOf(manifestPath: string): string { - return manifestPath.replace(/[\\/][^\\/]+$/, ''); -} - -/** - * A snapshot path is engine-authored and constrained to `provenance/modules/`. - * Re-check it here anyway: this value drives a file read, and the manifest is - * untrusted input. - */ -function isSafeSnapshotPath(snapshotPath: string | undefined): snapshotPath is string { - if (!snapshotPath) return false; - const normalized = snapshotPath.replace(/\\/g, '/'); - return normalized.startsWith('provenance/modules/') && !normalized.includes('..'); -} - -/** - * Read the module snapshots a v2 bundle ships, for their display names. - * - * Best-effort by design: a bundle missing or carrying an unreadable snapshot - * still summarizes fine, it just falls back to app-derived labels. - */ -async function readSnapshotDisplayNames( +/** Reads one extracted profile through the engine; the GUI never parses its manifest. */ +export async function inspectProfileContents( + settings: AppSettings, manifestPath: string, - manifest: ProfileManifest, -): Promise> { - const paths = new Set(); - for (const capture of manifest.configCaptures ?? []) { - const snapshotPath = capture?.captureModule?.snapshotPath; - if (isSafeSnapshotPath(snapshotPath)) paths.add(snapshotPath.replace(/\\/g, '/')); - } - if (paths.size === 0) return {}; - - const dir = bundleDirOf(manifestPath); - const names: Record = {}; - - await Promise.all( - [...paths].map(async (snapshotPath) => { - try { - const path = `${dir}\\${snapshotPath.replace(/\//g, '\\')}`; - const content = await invoke('read_text_file', { path }); - const snapshot = parseJsonc(content); - if (snapshot?.id && snapshot.displayName) { - names[snapshot.id] = snapshot.displayName; - } - } catch { - // A missing or malformed snapshot is not an error the user needs to see. - } - }), +): Promise { + const result = await runEndstateOnce>( + settings, + "profile", + ["inspect", manifestPath], ); - - return names; -} - -/** - * Load and summarize an extracted profile. - * - * @param manifestPath absolute path to the profile's `manifest.jsonc` - * (`DiscoveredProfile.path`). - * @throws when the manifest cannot be read or parsed. - */ -export async function loadProfileContents(manifestPath: string): Promise { - const content = await invoke('read_text_file', { path: manifestPath }); - const manifest = parseJsonc(content); - const snapshotNames = await readSnapshotDisplayNames(manifestPath, manifest); - return summarizeProfileManifest(manifest, snapshotNames); + if (!result.success) { + const envelope = result.envelope as EndstateEnvelope | undefined; + if (envelope?.error) { + throw new ProfileInspectionError({ + code: envelope.error.code, + message: envelope.error.message, + remediation: envelope.error.remediation, + detail: envelope.error.detail, + }); + } + throw new ProfileInspectionError({ + code: result.error.kind.toUpperCase(), + message: result.error.message, + detail: result.error.stderr ? { stderr: result.error.stderr } : undefined, + }); + } + const parsed = inspectionEnvelopeSchema.safeParse(result.envelope); + if (!parsed.success) throw incompatibleInspectionResponse(); + const data = parsed.data.data as ProfileInspectionData; + validateInspectionRelations(data, manifestPath); + return data; } diff --git a/src/types.ts b/src/types.ts index 06c4fbcd..0d18aabe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -165,6 +165,8 @@ export interface EndstateCapabilitiesData { streaming?: boolean; parallelInstall?: boolean; configModules?: boolean; + /** Additive read-only `profile inspect` capability (engine v2.30+). */ + profileInspection?: boolean; jsonOutput?: boolean; manualApps?: boolean; hostedBackup?: EndstateHostedBackupCapability; @@ -189,6 +191,71 @@ export interface EndstateCapabilitiesData { bootstrapTimestamp?: string | null; } +/** Engine-authored ownership classification for one app-settings row. */ +export type ProfileInspectionAssociationStatus = + | 'included' + | 'not_in_profile' + | 'ambiguous' + | 'unresolved'; + +/** Impact level for an engine-authored profile-inspection warning. */ +export type ProfileInspectionWarningImpact = 'diagnostic' | 'inventory_incomplete'; + +/** Saved-profile identity reported by `endstate profile inspect --json`. */ +export interface ProfileInspectionProfile { + name: string | null; + capturedAt: string | null; + manifestVersion: number; + manifestPath: string; +} + +/** Finalized inspection counts, derived by the engine from returned rows. */ +export interface ProfileInspectionSummary { + appCount: number; + settingsRowCount: number; + verifiedSettingsAppCount: number; + unidentifiedSettingsRowCount: number; +} + +/** One Apps-inventory row from the read-only profile inspection. */ +export interface ProfileInspectionApp { + id: string; + manifestAppId: string; + displayName: string; + packageRefs: string[]; + hasSettings: boolean; +} + +/** One grouped, profile-owned app-settings row from the inspection. */ +export interface ProfileInspectionSettingsApp { + id: string; + displayName: string; + associationStatus: ProfileInspectionAssociationStatus; + ownerId: string | null; + appId: string | null; + appIncluded: boolean; + packageRefs: string[]; + moduleIds: string[]; + candidateAppIds: string[]; + capturedEntryCount: number; +} + +/** Engine-authored profile-inspection warning. */ +export interface ProfileInspectionWarning { + code: string; + message: string; + impact: ProfileInspectionWarningImpact; +} + +/** Complete successful `profile inspect` payload. */ +export interface ProfileInspectionData { + profile: ProfileInspectionProfile; + summary: ProfileInspectionSummary; + apps: ProfileInspectionApp[]; + settingsApps: ProfileInspectionSettingsApp[]; + warnings: ProfileInspectionWarning[]; +} + // ----------------------------------------------------------------------------- // Scheduled drift check ("Continuous protection") // diff --git a/tests/contract.test.js b/tests/contract.test.js index c81543de..4095f1b0 100644 --- a/tests/contract.test.js +++ b/tests/contract.test.js @@ -32,13 +32,11 @@ function checkEndstateAvailable() { return true; } -function runEndstate(command, args = []) { +function runEndstate(command, args = [], options = {}) { return new Promise((resolve, reject) => { - const fullArgs = [ - command, - '--json', - ...args, - ]; + const fullArgs = options.jsonAfterArgs + ? [command, ...args, '--json'] + : [command, '--json', ...args]; const proc = spawn(ENDSTATE_ENGINE_PATH, fullArgs, { stdio: ['ignore', 'pipe', 'pipe'], @@ -137,9 +135,13 @@ async function testCapabilities() { if (!Array.isArray(applyFlags) || !applyFlags.includes('--restore-target')) { throw new Error('Pinned engine does not advertise apply --restore-target'); } + if (envelope.data?.features?.profileInspection !== true) { + throw new Error('Pinned engine does not advertise features.profileInspection=true'); + } console.log(' ✓ Envelope structure valid'); console.log(' ✓ Config generation target mapping supported'); + console.log(' ✓ Profile inspection supported'); // The pinned engine must report the version we pinned. Nothing else asserts // this: verify-engine-pin.yml proves only that the release exists and carries @@ -168,6 +170,183 @@ async function testCapabilities() { console.log(` ✓ Success: ${envelope.success}`); } +function assertExactKeys(value, expectedKeys, subject) { + const actual = Object.keys(value).sort(); + const expected = [...expectedKeys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${subject} keys differ: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function assertRowsSorted(rows, subject) { + const actual = rows.map((row) => row.id); + const expected = [...rows] + .sort((left, right) => { + const label = left.displayName.localeCompare(right.displayName, undefined, { sensitivity: 'accent' }); + return label || left.id.localeCompare(right.id); + }) + .map((row) => row.id); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${subject} is not deterministically ordered: ${JSON.stringify(actual)}`); + } +} + +async function testProfileInspectionAndRegenerateGolden() { + const profile = join(testDir, 'fixtures', 'profile-inspect-profile', 'manifest.jsonc'); + console.log('\n📋 Testing: endstate profile inspect --json'); + + const result = await runEndstate('profile', ['inspect', profile], { jsonAfterArgs: true }); + if (result.exitCode !== 0) { + throw new Error(`profile inspect exited with code ${result.exitCode}: ${result.stderr.trim()}`); + } + + const envelope = parseEnvelope(result.stdout); + if (!/^1\.\d+$/.test(envelope.schemaVersion)) { + throw new Error(`Expected schema 1.x, got ${JSON.stringify(envelope.schemaVersion)}`); + } + if (envelope.command !== 'profile' || envelope.success !== true || envelope.error !== null) { + throw new Error(`Expected successful profile envelope, got ${JSON.stringify(envelope)}`); + } + assertExactKeys( + envelope, + ['schemaVersion', 'cliVersion', 'command', 'runId', 'timestampUtc', 'success', 'data', 'error'], + 'profile inspect envelope' + ); + + const data = envelope.data; + assertExactKeys(data, ['profile', 'apps', 'settingsApps', 'warnings', 'summary'], 'profile inspect data'); + assertExactKeys(data.profile, ['name', 'capturedAt', 'manifestVersion', 'manifestPath'], 'profile inspect profile'); + assertExactKeys( + data.summary, + ['appCount', 'settingsRowCount', 'verifiedSettingsAppCount', 'unidentifiedSettingsRowCount'], + 'profile inspect summary' + ); + + if (data.profile.name !== null || data.profile.capturedAt !== null || data.profile.manifestVersion !== 2) { + throw new Error(`Fixture must preserve nullable profile metadata, got ${JSON.stringify(data.profile)}`); + } + if (data.profile.manifestPath !== profile) { + throw new Error(`profile inspect returned the wrong manifest path: ${JSON.stringify(data.profile.manifestPath)}`); + } + if (!Array.isArray(data.apps) || data.apps.length < 2) { + throw new Error('Expected at least two Apps rows from the profile inspection fixture'); + } + if (!Array.isArray(data.settingsApps) || data.settingsApps.length < 2 || !Array.isArray(data.warnings)) { + throw new Error('Expected non-empty settingsApps and non-null warnings arrays from profile inspect'); + } + + for (const app of data.apps) { + assertExactKeys(app, ['id', 'manifestAppId', 'displayName', 'packageRefs', 'hasSettings'], 'profile inspect app'); + if (!Array.isArray(app.packageRefs)) { + throw new Error(`App ${app.id} has a non-array packageRefs field`); + } + } + assertRowsSorted(data.apps, 'Apps rows'); + + const statusCounts = new Map(); + const settingsRowIds = new Set(); + const verifiedOwnerIds = new Set(); + for (const row of data.settingsApps) { + assertExactKeys( + row, + ['id', 'displayName', 'associationStatus', 'ownerId', 'appId', 'appIncluded', 'packageRefs', 'moduleIds', 'candidateAppIds', 'capturedEntryCount'], + 'profile inspect settings row' + ); + for (const field of ['packageRefs', 'moduleIds', 'candidateAppIds']) { + if (!Array.isArray(row[field])) { + throw new Error(`Settings row ${row.id} has a non-array ${field} field`); + } + } + if (settingsRowIds.has(row.id)) { + throw new Error(`Duplicate profile inspect settings row id ${JSON.stringify(row.id)}`); + } + settingsRowIds.add(row.id); + statusCounts.set(row.associationStatus, (statusCounts.get(row.associationStatus) || 0) + 1); + const isIncluded = row.associationStatus === 'included'; + const isAbsent = row.associationStatus === 'not_in_profile'; + const isAmbiguous = row.associationStatus === 'ambiguous'; + const isUnresolved = row.associationStatus === 'unresolved'; + if (!isIncluded && !isAbsent && !isAmbiguous && !isUnresolved) { + throw new Error(`Unexpected associationStatus ${JSON.stringify(row.associationStatus)}`); + } + if ((isIncluded || isAbsent) !== (typeof row.ownerId === 'string')) { + throw new Error(`ownerId/status matrix mismatch for ${row.id}`); + } + if (isIncluded || isAbsent) { + if (verifiedOwnerIds.has(row.ownerId)) { + throw new Error(`Duplicate verified ownerId ${JSON.stringify(row.ownerId)}`); + } + verifiedOwnerIds.add(row.ownerId); + } + if (isIncluded !== (typeof row.appId === 'string') || isIncluded !== row.appIncluded) { + throw new Error(`appId/appIncluded/status matrix mismatch for ${row.id}`); + } + if (isIncluded && (row.candidateAppIds.length !== 1 || row.candidateAppIds[0] !== row.appId)) { + throw new Error(`included candidate matrix mismatch for ${row.id}`); + } + if ((!isIncluded && !isAmbiguous) && row.candidateAppIds.length !== 0) { + throw new Error(`non-candidate row ${row.id} has candidate IDs`); + } + } + assertRowsSorted(data.settingsApps, 'Settings rows'); + + if (statusCounts.get('included') !== 1 || statusCounts.get('not_in_profile') !== 1) { + throw new Error(`Fixture must yield one included and one not_in_profile row, got ${JSON.stringify(Object.fromEntries(statusCounts))}`); + } + if (data.summary.appCount !== data.apps.length || data.summary.settingsRowCount !== data.settingsApps.length) { + throw new Error(`Summary inventory counts differ from finalized arrays: ${JSON.stringify(data.summary)}`); + } + const verified = verifiedOwnerIds.size; + const unidentified = data.settingsApps.filter( + (row) => row.associationStatus === 'ambiguous' || row.associationStatus === 'unresolved' + ).length; + if (data.summary.verifiedSettingsAppCount !== verified || data.summary.unidentifiedSettingsRowCount !== unidentified) { + throw new Error(`Summary association counts differ from finalized rows: ${JSON.stringify(data.summary)}`); + } + const included = data.settingsApps.find((row) => row.associationStatus === 'included'); + const absent = data.settingsApps.find((row) => row.associationStatus === 'not_in_profile'); + if ( + included?.appId !== 'app:included-app:1' + || included.ownerId !== included.appId + || JSON.stringify(included.packageRefs) !== JSON.stringify(['Vendor.Included']) + || JSON.stringify(included.moduleIds) !== JSON.stringify(['apps.included']) + ) { + throw new Error(`Included settings fixture semantics drifted: ${JSON.stringify(included)}`); + } + if ( + absent?.ownerId !== 'package:vendor.absent' + || absent.appId !== null + || absent.appIncluded !== false + || JSON.stringify(absent.packageRefs) !== JSON.stringify(['Vendor.Absent']) + || JSON.stringify(absent.moduleIds) !== JSON.stringify(['apps.absent']) + ) { + throw new Error(`Absent-owner settings fixture semantics drifted: ${JSON.stringify(absent)}`); + } + + // The engine returns the input's absolute path verbatim. It is asserted above, + // but normalized only in the committed golden because a CI checkout path is not + // part of the inspection contract's semantic inventory. + const goldenData = JSON.parse(JSON.stringify(data)); + goldenData.profile.manifestPath = ''; + const golden = { + _generatedBy: 'tests/contract.test.js against Endstate v2.30.0 — do not hand-edit', + schemaVersion: envelope.schemaVersion, + command: envelope.command, + success: envelope.success, + error: envelope.error, + data: goldenData, + }; + const goldenPath = join(testDir, 'fixtures', 'profile-inspect-envelope.golden.json'); + const previous = existsSync(goldenPath) ? readFileSync(goldenPath, 'utf8') : ''; + const next = JSON.stringify(golden, null, 2) + '\n'; + if (previous !== next) { + writeFileSync(goldenPath, next); + console.log(' ⚠ profile inspection golden regenerated — commit the diff'); + } else { + console.log(' ✓ profile inspection golden unchanged'); + } +} + async function testReport() { console.log('\n📋 Testing: endstate report --json'); @@ -862,6 +1041,7 @@ async function runTests() { await testReport(); await testVerifyMissing(); await testApplyMissing(); + await testProfileInspectionAndRegenerateGolden(); await testApplyPayloadAndRegenerateGolden(); await testRestorePayloadAndRegenerateGolden(); await testMixedPartialFailure(); diff --git a/tests/fixtures/profile-inspect-envelope.golden.json b/tests/fixtures/profile-inspect-envelope.golden.json new file mode 100644 index 00000000..0ea0ee85 --- /dev/null +++ b/tests/fixtures/profile-inspect-envelope.golden.json @@ -0,0 +1,78 @@ +{ + "_generatedBy": "tests/contract.test.js against Endstate v2.30.0 — do not hand-edit", + "schemaVersion": "1.0", + "command": "profile", + "success": true, + "error": null, + "data": { + "profile": { + "name": null, + "capturedAt": null, + "manifestVersion": 2, + "manifestPath": "" + }, + "apps": [ + { + "id": "app:included-app:1", + "manifestAppId": "included-app", + "displayName": "Included App", + "packageRefs": [ + "Vendor.Included" + ], + "hasSettings": true + }, + { + "id": "app:other-app:1", + "manifestAppId": "other-app", + "displayName": "Other App", + "packageRefs": [ + "Vendor.Other" + ], + "hasSettings": false + } + ], + "settingsApps": [ + { + "id": "settings:package:vendor.absent", + "displayName": "Absent settings", + "associationStatus": "not_in_profile", + "ownerId": "package:vendor.absent", + "appId": null, + "appIncluded": false, + "packageRefs": [ + "Vendor.Absent" + ], + "moduleIds": [ + "apps.absent" + ], + "candidateAppIds": [], + "capturedEntryCount": 0 + }, + { + "id": "settings:app:included-app:1", + "displayName": "Included settings", + "associationStatus": "included", + "ownerId": "app:included-app:1", + "appId": "app:included-app:1", + "appIncluded": true, + "packageRefs": [ + "Vendor.Included" + ], + "moduleIds": [ + "apps.included" + ], + "candidateAppIds": [ + "app:included-app:1" + ], + "capturedEntryCount": 0 + } + ], + "warnings": [], + "summary": { + "appCount": 2, + "settingsRowCount": 2, + "verifiedSettingsAppCount": 2, + "unidentifiedSettingsRowCount": 0 + } + } +} diff --git a/tests/fixtures/profile-inspect-profile/manifest.jsonc b/tests/fixtures/profile-inspect-profile/manifest.jsonc new file mode 100644 index 00000000..6f3b455c --- /dev/null +++ b/tests/fixtures/profile-inspect-profile/manifest.jsonc @@ -0,0 +1,67 @@ +{ + "version": 2, + "apps": [ + { + "id": "included-app", + "displayName": "Included App", + "refs": { "windows": "Vendor.Included" } + }, + { + "id": "other-app", + "displayName": "Other App", + "refs": { "windows": "Vendor.Other" } + } + ], + "configCaptures": [ + { + "captureId": "included-capture", + "moduleId": "apps.included", + "configSetId": "preferences", + "sourceInstance": { + "id": "included-instance", + "detectorId": "package", + "rawVersion": "1.0", + "normalizedVersion": "1", + "evidence": { + "type": "package", + "backend": "winget", + "ref": "Vendor.Included" + } + }, + "sourceGeneration": "v1", + "sourceGenerationFingerprint": "0000000000000000000000000000000000000000000000000000000000000000", + "captureModule": { + "schemaVersion": 2, + "contentHash": "c585718149bcaa1d85c8cb12ff3504dd4621abbf6606eaec876a453f24619e3a", + "snapshotPath": "provenance/modules/included.json" + }, + "payloadRoot": "configs/included-capture", + "payloadManifest": [] + }, + { + "captureId": "absent-capture", + "moduleId": "apps.absent", + "configSetId": "preferences", + "sourceInstance": { + "id": "absent-instance", + "detectorId": "package", + "rawVersion": "1.0", + "normalizedVersion": "1", + "evidence": { + "type": "package", + "backend": "winget", + "ref": "Vendor.Absent" + } + }, + "sourceGeneration": "v1", + "sourceGenerationFingerprint": "0000000000000000000000000000000000000000000000000000000000000000", + "captureModule": { + "schemaVersion": 2, + "contentHash": "d9b155431093bff05fddc73cb2eb8590dfa29c41ce443c32fc134222d6a40764", + "snapshotPath": "provenance/modules/absent.json" + }, + "payloadRoot": "configs/absent-capture", + "payloadManifest": [] + } + ] +} diff --git a/tests/fixtures/profile-inspect-profile/metadata.json b/tests/fixtures/profile-inspect-profile/metadata.json new file mode 100644 index 00000000..58679935 --- /dev/null +++ b/tests/fixtures/profile-inspect-profile/metadata.json @@ -0,0 +1,3 @@ +{ + "schemaVersion": "1.0" +} diff --git a/tests/fixtures/profile-inspect-profile/provenance/modules/absent.json b/tests/fixtures/profile-inspect-profile/provenance/modules/absent.json new file mode 100644 index 00000000..6d617588 --- /dev/null +++ b/tests/fixtures/profile-inspect-profile/provenance/modules/absent.json @@ -0,0 +1,7 @@ +{ + "moduleSchemaVersion": 2, + "id": "apps.absent", + "displayName": "Absent settings", + "sensitivity": "none", + "matches": { "winget": ["Vendor.Absent"] } +} diff --git a/tests/fixtures/profile-inspect-profile/provenance/modules/included.json b/tests/fixtures/profile-inspect-profile/provenance/modules/included.json new file mode 100644 index 00000000..2b49e577 --- /dev/null +++ b/tests/fixtures/profile-inspect-profile/provenance/modules/included.json @@ -0,0 +1,7 @@ +{ + "moduleSchemaVersion": 2, + "id": "apps.included", + "displayName": "Included settings", + "sensitivity": "none", + "matches": { "winget": ["Vendor.Included"] } +}