From bbdb12385523059fe2a405103bde8d21518b5a4c Mon Sep 17 00:00:00 2001
From: Artexis10
Date: Thu, 30 Jul 2026 09:18:24 +0300
Subject: [PATCH 01/14] docs(openspec): specify accurate profile contents
---
.../.openspec.yaml | 2 +
.../fix-profile-contents-accuracy/design.md | 139 ++++++++++++++
.../fix-profile-contents-accuracy/proposal.md | 29 +++
.../specs/profile-contents-inspection/spec.md | 178 ++++++++++++++++++
.../fix-profile-contents-accuracy/tasks.md | 37 ++++
5 files changed, 385 insertions(+)
create mode 100644 openspec/changes/fix-profile-contents-accuracy/.openspec.yaml
create mode 100644 openspec/changes/fix-profile-contents-accuracy/design.md
create mode 100644 openspec/changes/fix-profile-contents-accuracy/proposal.md
create mode 100644 openspec/changes/fix-profile-contents-accuracy/specs/profile-contents-inspection/spec.md
create mode 100644 openspec/changes/fix-profile-contents-accuracy/tasks.md
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 0000000..ab39675
--- /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 0000000..ef96800
--- /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 0000000..95b4882
--- /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 0000000..8896827
--- /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 0000000..81e0ae5
--- /dev/null
+++ b/openspec/changes/fix-profile-contents-accuracy/tasks.md
@@ -0,0 +1,37 @@
+## 1. Establish The Cross-Repo Contract
+
+- [ ] 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.
+- [ ] 1.2 Create the engine OpenSpec change for `profile inspect`, the additive `features.profileInspection` capability, and the no-machine-evaluation invariant.
+- [ ] 1.3 Update the engine profile, CLI JSON, and GUI integration contracts with the structured inspection envelope and label/ownership precedence.
+- [ ] 1.4 Validate both repositories' OpenSpec changes strictly before implementation.
+
+## 2. Implement Read-Only Engine Inspection
+
+- [ ] 2.1 Add failing engine tests for command routing, capability advertisement, non-null deterministic output, and structured error envelopes.
+- [ ] 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.
+- [ ] 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.
+- [ ] 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.
+- [ ] 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
+
+- [ ] 3.1 Add failing GUI contract tests for `features.profileInspection`, the `profile inspect` invocation, schema validation, stale-engine handling, and unreadable-profile errors.
+- [ ] 3.2 Add typed profile-inspection envelope models and capability detection, then route **What's inside** through the dedicated engine command.
+- [ ] 3.3 Remove the GUI-owned settings ownership/label inference path while retaining only structural presentation helpers such as search filtering and pluralization.
+- [ ] 3.4 Update the bundled-engine revision/resources and contract fixtures to the reviewed engine build.
+
+## 4. Build The Two-Tab Modal
+
+- [ ] 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.
+- [ ] 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.
+- [ ] 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.
+- [ ] 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.
+- [ ] 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
+
+- [ ] 5.1 Run the targeted profile-content/component/Setup tests, TypeScript check, production build, contract tests, and strict OpenSpec validation.
+- [ ] 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.
+- [ ] 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.
From 88d2f51e62d98d8a63b9e3bcafc04848b0f5a93f Mon Sep 17 00:00:00 2001
From: Artexis10
Date: Sat, 1 Aug 2026 15:03:03 +0300
Subject: [PATCH 02/14] docs(openspec): record profile contents archive
---
openspec/changes/fix-profile-contents-accuracy/tasks.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/openspec/changes/fix-profile-contents-accuracy/tasks.md b/openspec/changes/fix-profile-contents-accuracy/tasks.md
index 81e0ae5..c1d0c6f 100644
--- a/openspec/changes/fix-profile-contents-accuracy/tasks.md
+++ b/openspec/changes/fix-profile-contents-accuracy/tasks.md
@@ -1,6 +1,6 @@
## 1. Establish The Cross-Repo Contract
-- [ ] 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.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.
- [ ] 1.2 Create the engine OpenSpec change for `profile inspect`, the additive `features.profileInspection` capability, and the no-machine-evaluation invariant.
- [ ] 1.3 Update the engine profile, CLI JSON, and GUI integration contracts with the structured inspection envelope and label/ownership precedence.
- [ ] 1.4 Validate both repositories' OpenSpec changes strictly before implementation.
From 2e442a6ae3ed628609b86b25b741ac1ac634541d Mon Sep 17 00:00:00 2001
From: Artexis10
Date: Sat, 1 Aug 2026 15:30:09 +0300
Subject: [PATCH 03/14] docs(openspec): record profile inspection contract
---
openspec/changes/fix-profile-contents-accuracy/tasks.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/openspec/changes/fix-profile-contents-accuracy/tasks.md b/openspec/changes/fix-profile-contents-accuracy/tasks.md
index c1d0c6f..adc32be 100644
--- a/openspec/changes/fix-profile-contents-accuracy/tasks.md
+++ b/openspec/changes/fix-profile-contents-accuracy/tasks.md
@@ -1,9 +1,9 @@
## 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.
-- [ ] 1.2 Create the engine OpenSpec change for `profile inspect`, the additive `features.profileInspection` capability, and the no-machine-evaluation invariant.
-- [ ] 1.3 Update the engine profile, CLI JSON, and GUI integration contracts with the structured inspection envelope and label/ownership precedence.
-- [ ] 1.4 Validate both repositories' OpenSpec changes strictly before implementation.
+- [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
From 5f25aec304c349a0fa1e595d9a725bb79bc5d59a Mon Sep 17 00:00:00 2001
From: Artexis10
Date: Sat, 1 Aug 2026 17:28:55 +0300
Subject: [PATCH 04/14] feat(profile): add inspection response boundary
---
src/lib/profile-contents.test.ts | 272 +++++++++++++++++++++++++++++++
src/lib/profile-contents.ts | 207 ++++++++++++++++++++++-
src/types.ts | 67 ++++++++
3 files changed, 542 insertions(+), 4 deletions(-)
diff --git a/src/lib/profile-contents.test.ts b/src/lib/profile-contents.test.ts
index 33341a5..50ddea2 100644
--- a/src/lib/profile-contents.test.ts
+++ b/src/lib/profile-contents.test.ts
@@ -1,17 +1,133 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
appLabel,
+ inspectProfileContents,
loadProfileContents,
moduleIdFromRestoreSource,
+ ProfileInspectionError,
shortModuleId,
summarizeProfileManifest,
} from './profile-contents';
import type { ProfileManifest } from './jsonc-parse';
+import type { AppSettings } from '../settings';
vi.mock('./tauri-bridge', () => ({
invoke: vi.fn(),
}));
+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,
+ 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: 'app:one:1',
+ manifestAppId: 'one',
+ displayName: 'One',
+ packageRefs: ['Example.One'],
+ hasSettings: true,
+ },
+ {
+ id: 'app:two:1',
+ manifestAppId: 'two',
+ displayName: 'Two',
+ packageRefs: [],
+ hasSettings: false,
+ },
+ ],
+ 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,
+ },
+ {
+ 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,
+ },
+ {
+ id: 'settings:module:unresolved',
+ displayName: 'Unresolved settings',
+ associationStatus: 'unresolved',
+ ownerId: null,
+ appId: null,
+ appIncluded: false,
+ packageRefs: [],
+ moduleIds: ['unresolved'],
+ candidateAppIds: [],
+ capturedEntryCount: 0,
+ },
+ ],
+ warnings: [{ code: 'LEGACY', message: 'Legacy metadata', impact: 'diagnostic' }],
+ },
+ error: null,
+ };
+}
+
+async function mockInspection(envelope = inspectionEnvelope()) {
+ const { runEndstateOnce } = await import('./engine-exec');
+ vi.mocked(runEndstateOnce).mockResolvedValue({
+ success: true,
+ envelope,
+ stdout: JSON.stringify(envelope),
+ stderr: '',
+ exitCode: 0,
+ } as never);
+}
+
describe('moduleIdFromRestoreSource', () => {
it('derives the module id from the bundle-relative configs path', () => {
expect(moduleIdFromRestoreSource('./configs/notepad-plus-plus/config.xml')).toBe(
@@ -308,3 +424,159 @@ describe('loadProfileContents', () => {
await expect(loadProfileContents('C:\\Setups\\broken\\manifest.jsonc')).rejects.toThrow();
});
});
+
+describe('inspectProfileContents', () => {
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ await mockInspection();
+ });
+
+ it('uses the current settings and only profile inspect with the manifest path', async () => {
+ const contents = await inspectProfileContents(SETTINGS, 'C:\\Profiles\\example\\manifest.jsonc');
+ const { runEndstateOnce } = await import('./engine-exec');
+
+ expect(runEndstateOnce).toHaveBeenCalledWith(SETTINGS, 'profile', [
+ 'inspect',
+ 'C:\\Profiles\\example\\manifest.jsonc',
+ ]);
+ expect(contents.apps.map((app) => app.displayName)).toEqual(['One', 'Two']);
+ });
+
+ it('preserves a valid engine result exactly, including explicit nulls and engine order', async () => {
+ const envelope = inspectionEnvelope();
+ (envelope.data as Record).extra = { future: true };
+ (envelope.data.apps[0] as Record).extra = 'allowed';
+ await mockInspection(envelope);
+
+ await expect(inspectProfileContents(SETTINGS, 'C:\\Profiles\\example\\manifest.jsonc')).resolves.toEqual(
+ envelope.data,
+ );
+ });
+
+ it.each([
+ ['non-1.x schema', (envelope: any) => { envelope.schemaVersion = '2.0'; }],
+ ['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', message: 'bad' }; }],
+ ])('fails closed on a %s success envelope', 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.each([
+ ['apps', (envelope: any) => { delete envelope.data.apps; }],
+ ['settingsApps', (envelope: any) => { envelope.data.settingsApps = null; }],
+ ['warnings', (envelope: any) => { delete envelope.data.warnings; }],
+ ['app packageRefs', (envelope: any) => { envelope.data.apps[0].packageRefs = null; }],
+ ['settings packageRefs', (envelope: any) => { delete envelope.data.settingsApps[0].packageRefs; }],
+ ['settings moduleIds', (envelope: any) => { envelope.data.settingsApps[0].moduleIds = null; }],
+ ['settings candidateAppIds', (envelope: any) => { delete envelope.data.settingsApps[0].candidateAppIds; }],
+ ['profile name', (envelope: any) => { delete envelope.data.profile.name; }],
+ ['profile capturedAt', (envelope: any) => { delete envelope.data.profile.capturedAt; }],
+ ['row ownerId', (envelope: any) => { delete envelope.data.settingsApps[0].ownerId; }],
+ ['row appId', (envelope: any) => { delete envelope.data.settingsApps[0].appId; }],
+ ])('rejects a missing or null required %s field', 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.each([
+ ['negative app count', (envelope: any) => { envelope.data.summary.appCount = -1; }],
+ ['fractional app count', (envelope: any) => { envelope.data.summary.appCount = 1.5; }],
+ ['negative captured entry count', (envelope: any) => { envelope.data.settingsApps[0].capturedEntryCount = -1; }],
+ ['unknown association status', (envelope: any) => { envelope.data.settingsApps[0].associationStatus = 'maybe'; }],
+ ['unknown warning impact', (envelope: any) => { envelope.data.warnings[0].impact = 'unknown'; }],
+ ])('rejects an invalid %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.each([
+ ['included has a null owner', (row: any) => { row.ownerId = null; }],
+ ['included has a different app id', (row: any) => { row.appId = 'app:two:1'; }],
+ ['included is not marked included', (row: any) => { row.appIncluded = false; }],
+ ['included has no sole candidate', (row: any) => { row.candidateAppIds = []; }],
+ ['not in profile has an app id', (row: any) => { row.appId = 'app:one:1'; }],
+ ['not in profile is marked included', (row: any) => { row.appIncluded = true; }],
+ ['ambiguous has an owner', (row: any) => { row.ownerId = 'app:one:1'; }],
+ ['ambiguous has no candidates', (row: any) => { row.candidateAppIds = []; }],
+ ['unresolved has candidates', (row: any) => { row.candidateAppIds = ['app:one:1']; }],
+ ])('rejects when %s', async (_name, mutate) => {
+ const envelope = inspectionEnvelope();
+ const rows = envelope.data.settingsApps;
+ const row = rows.find((candidate) => {
+ if (_name.startsWith('included')) return candidate.associationStatus === 'included';
+ if (_name.startsWith('not in profile')) return candidate.associationStatus === 'not_in_profile';
+ if (_name.startsWith('ambiguous')) return candidate.associationStatus === 'ambiguous';
+ return candidate.associationStatus === 'unresolved';
+ });
+ mutate(row);
+ await mockInspection(envelope);
+
+ await expect(inspectProfileContents(SETTINGS, 'C:\\Profiles\\example\\manifest.jsonc')).rejects.toThrow(
+ /incompatible profile inspection response/i,
+ );
+ });
+
+ it('rejects mismatched summary counts, invalid references, and hasSettings disagreement', async () => {
+ const mismatch = inspectionEnvelope();
+ mismatch.data.summary.appCount = 1;
+ await mockInspection(mismatch);
+ await expect(inspectProfileContents(SETTINGS, 'C:\\Profiles\\example\\manifest.jsonc')).rejects.toThrow(
+ /incompatible profile inspection response/i,
+ );
+
+ const invalidReference = inspectionEnvelope();
+ invalidReference.data.settingsApps[0].appId = 'app:missing:1';
+ invalidReference.data.settingsApps[0].ownerId = 'app:missing:1';
+ invalidReference.data.settingsApps[0].candidateAppIds = ['app:missing:1'];
+ await mockInspection(invalidReference);
+ await expect(inspectProfileContents(SETTINGS, 'C:\\Profiles\\example\\manifest.jsonc')).rejects.toThrow(
+ /incompatible profile inspection response/i,
+ );
+
+ const hasSettingsMismatch = inspectionEnvelope();
+ hasSettingsMismatch.data.apps[0].hasSettings = false;
+ await mockInspection(hasSettingsMismatch);
+ await expect(inspectProfileContents(SETTINGS, 'C:\\Profiles\\example\\manifest.jsonc')).rejects.toThrow(
+ /incompatible profile inspection response/i,
+ );
+ });
+
+ it('surfaces a structured engine failure with its code and message', async () => {
+ const { runEndstateOnce } = await import('./engine-exec');
+ vi.mocked(runEndstateOnce).mockResolvedValue({
+ success: false,
+ error: { kind: 'command_failed', message: 'Manifest was invalid' },
+ envelope: {
+ schemaVersion: '1.0',
+ command: 'profile',
+ success: false,
+ data: null,
+ 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' },
+ );
+ 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 d1449b6..edfe2c5 100644
--- a/src/lib/profile-contents.ts
+++ b/src/lib/profile-contents.ts
@@ -1,10 +1,9 @@
/**
* "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.
+ * The legacy local loader remains temporarily while the dialog migration lands.
+ * New callers use `inspectProfileContents`, which delegates all ownership and
+ * label semantics to the engine's read-only `profile inspect` command.
*
* 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
@@ -15,6 +14,13 @@
*/
import { invoke } from './tauri-bridge';
+import { runEndstateOnce } from './engine-exec';
+import { z } from 'zod';
+import type { AppSettings } from '../settings';
+import type {
+ EndstateEnvelope,
+ ProfileInspectionData,
+} from '../types';
import {
parseJsonc,
type ProfileApp,
@@ -278,3 +284,196 @@ export async function loadProfileContents(manifestPath: string): Promise;
+
+ 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;
+ }
+}
+
+function incompatibleInspectionResponse(): ProfileInspectionError {
+ return new ProfileInspectionError({
+ code: 'INCOMPATIBLE_PROFILE_INSPECTION_RESPONSE',
+ message: 'Incompatible profile inspection response. Please update Endstate and try again.',
+ });
+}
+
+function validateInspectionRelations(data: ProfileInspectionData): void {
+ const appsById = new Map(data.apps.map((app) => [app.id, app]));
+ if (appsById.size !== data.apps.length) throw incompatibleInspectionResponse();
+
+ const includedAppIds = new Set();
+ let verifiedSettingsAppCount = 0;
+ let unidentifiedSettingsRowCount = 0;
+
+ for (const row of data.settingsApps) {
+ const candidatesResolve = row.candidateAppIds.every((candidateId) => appsById.has(candidateId));
+ if (!candidatesResolve) throw incompatibleInspectionResponse();
+
+ 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);
+ verifiedSettingsAppCount += 1;
+ break;
+ case 'not_in_profile':
+ if (!row.ownerId || row.appId !== null || row.appIncluded || row.candidateAppIds.length !== 0) {
+ throw incompatibleInspectionResponse();
+ }
+ verifiedSettingsAppCount += 1;
+ break;
+ case 'ambiguous':
+ if (
+ row.ownerId !== null ||
+ row.appId !== null ||
+ row.appIncluded ||
+ row.candidateAppIds.length === 0
+ ) {
+ 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;
+ }
+ }
+
+ for (const app of data.apps) {
+ if (app.hasSettings !== includedAppIds.has(app.id)) throw incompatibleInspectionResponse();
+ }
+
+ if (
+ data.summary.appCount !== data.apps.length ||
+ data.summary.settingsRowCount !== data.settingsApps.length ||
+ data.summary.verifiedSettingsAppCount !== verifiedSettingsAppCount ||
+ data.summary.unidentifiedSettingsRowCount !== unidentifiedSettingsRowCount
+ ) {
+ throw incompatibleInspectionResponse();
+ }
+}
+
+/**
+ * Read one saved, extracted profile through the engine's inspection boundary.
+ * The GUI deliberately does not read the manifest or snapshots on this path.
+ */
+export async function inspectProfileContents(
+ settings: AppSettings,
+ manifestPath: string,
+): Promise {
+ const result = await runEndstateOnce>(
+ settings,
+ 'profile',
+ ['inspect', manifestPath],
+ );
+
+ 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);
+ return data;
+}
diff --git a/src/types.ts b/src/types.ts
index 06c4fbc..0d18aab 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")
//
From c28d813099282a3fc80f0e636b0d4fdb4332eebd Mon Sep 17 00:00:00 2001
From: Artexis10
Date: Sat, 1 Aug 2026 17:36:48 +0300
Subject: [PATCH 05/14] fix(profile): tighten inspection response validation
---
src/lib/profile-contents.test.ts | 11 +++++++++++
src/lib/profile-contents.ts | 8 ++++++--
2 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/src/lib/profile-contents.test.ts b/src/lib/profile-contents.test.ts
index 50ddea2..d25fc85 100644
--- a/src/lib/profile-contents.test.ts
+++ b/src/lib/profile-contents.test.ts
@@ -455,9 +455,18 @@ describe('inspectProfileContents', () => {
it.each([
['non-1.x schema', (envelope: any) => { envelope.schemaVersion = '2.0'; }],
+ ['incomplete schema version', (envelope: any) => { envelope.schemaVersion = '1.'; }],
+ ['non-numeric schema minor', (envelope: any) => { envelope.schemaVersion = '1.bad'; }],
+ ['schema version suffix', (envelope: any) => { envelope.schemaVersion = '1.0-junk'; }],
['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', message: 'bad' }; }],
+ ['missing cli version', (envelope: any) => { delete envelope.cliVersion; }],
+ ['empty cli version', (envelope: any) => { envelope.cliVersion = ''; }],
+ ['missing run id', (envelope: any) => { delete envelope.runId; }],
+ ['empty run id', (envelope: any) => { envelope.runId = ''; }],
+ ['missing timestamp', (envelope: any) => { delete envelope.timestampUtc; }],
+ ['empty timestamp', (envelope: any) => { envelope.timestampUtc = ''; }],
])('fails closed on a %s success envelope', async (_name, mutate) => {
const envelope = inspectionEnvelope();
mutate(envelope);
@@ -515,6 +524,8 @@ describe('inspectProfileContents', () => {
['not in profile is marked included', (row: any) => { row.appIncluded = true; }],
['ambiguous has an owner', (row: any) => { row.ownerId = 'app:one:1'; }],
['ambiguous has no candidates', (row: any) => { row.candidateAppIds = []; }],
+ ['ambiguous has only one candidate', (row: any) => { row.candidateAppIds = ['app:one:1']; }],
+ ['ambiguous repeats a candidate', (row: any) => { row.candidateAppIds = ['app:one:1', 'app:one:1']; }],
['unresolved has candidates', (row: any) => { row.candidateAppIds = ['app:one:1']; }],
])('rejects when %s', async (_name, mutate) => {
const envelope = inspectionEnvelope();
diff --git a/src/lib/profile-contents.ts b/src/lib/profile-contents.ts
index edfe2c5..80dfffc 100644
--- a/src/lib/profile-contents.ts
+++ b/src/lib/profile-contents.ts
@@ -332,8 +332,11 @@ const inspectionDataSchema = z.object({
}).passthrough();
const inspectionEnvelopeSchema = z.object({
- schemaVersion: z.string().regex(/^1\./),
+ 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(),
@@ -405,7 +408,8 @@ function validateInspectionRelations(data: ProfileInspectionData): void {
row.ownerId !== null ||
row.appId !== null ||
row.appIncluded ||
- row.candidateAppIds.length === 0
+ row.candidateAppIds.length < 2 ||
+ new Set(row.candidateAppIds).size !== row.candidateAppIds.length
) {
throw incompatibleInspectionResponse();
}
From 2e7d464e8d787e92e044ba1e82cce626ef76b60a Mon Sep 17 00:00:00 2001
From: Artexis10
Date: Sat, 1 Aug 2026 17:53:00 +0300
Subject: [PATCH 06/14] fix(setup): show accurate searchable profile contents
---
.../fix-profile-contents-accuracy/tasks.md | 16 +-
src/App.tsx | 3 +
.../intent/profile-contents-modal.test.tsx | 518 +++++++++----
.../app/intent/profile-contents-modal.tsx | 469 ++++++++----
.../intent/setup-flow-whats-inside.test.tsx | 140 ++--
src/components/app/intent/setup-flow.tsx | 9 +
src/lib/profile-contents.test.ts | 716 ++++++------------
src/lib/profile-contents.ts | 471 +++---------
8 files changed, 1120 insertions(+), 1222 deletions(-)
diff --git a/openspec/changes/fix-profile-contents-accuracy/tasks.md b/openspec/changes/fix-profile-contents-accuracy/tasks.md
index adc32be..3791b40 100644
--- a/openspec/changes/fix-profile-contents-accuracy/tasks.md
+++ b/openspec/changes/fix-profile-contents-accuracy/tasks.md
@@ -15,18 +15,18 @@
## 3. Consume The Inspection Contract In The GUI
-- [ ] 3.1 Add failing GUI contract tests for `features.profileInspection`, the `profile inspect` invocation, schema validation, stale-engine handling, and unreadable-profile errors.
-- [ ] 3.2 Add typed profile-inspection envelope models and capability detection, then route **What's inside** through the dedicated engine command.
-- [ ] 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.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.
- [ ] 3.4 Update the bundled-engine revision/resources and contract fixtures to the reviewed engine build.
## 4. Build The Two-Tab Modal
-- [ ] 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.
-- [ ] 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.
-- [ ] 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.
-- [ ] 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.
-- [ ] 4.5 Update Set up flow integration tests to prove inspection remains independent of selection, preview, machine detection, and apply.
+- [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
diff --git a/src/App.tsx b/src/App.tsx
index b035a67..c093770 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 02f2084..e087f5f 100644
--- a/src/components/app/intent/profile-contents-modal.test.tsx
+++ b/src/components/app/intent/profile-contents-modal.test.tsx
@@ -1,232 +1,416 @@
-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 } from "vitest";
+import { render, screen, waitFor } 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();
+ it("renders semantic Apps and App settings totals from the engine result", async () => {
+ renderModal();
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ 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);
-
- render();
+ 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,
+ },
+ }),
+ );
- const dialog = await screen.findByRole('dialog');
- await waitFor(() => {
- expect(within(dialog).getByText('2 apps')).toBeVisible();
- });
- expect(within(dialog).getByText('2 settings')).toBeVisible();
+ 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();
});
- it('lists apps by display name', async () => {
- await mockManifest(V1_MANIFEST);
+ it("shows only the Apps tab initially and marks settings-bearing apps quietly", async () => {
+ renderModal();
- render();
-
- const apps = await appsSection();
- expect(within(apps).getByText('VLC media player')).toBeVisible();
- expect(within(apps).getByText('Notepad++')).toBeVisible();
+ const appsTab = await screen.findByRole("tab", { name: "Apps (2)" });
+ expect(appsTab).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByText("VLC media player")).toBeVisible();
+ expect(screen.getByText("Settings included")).toBeVisible();
+ expect(screen.queryByText("App not included")).not.toBeInTheDocument();
});
- it('lists settings modules by display name with their file counts', async () => {
- await mockManifest(V1_MANIFEST);
-
- render();
+ 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);
- 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();
+ 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();
});
- 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("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");
+ });
- 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)" });
- expect(await screen.findByText('jqlang.jq')).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('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: [
+ it("shows inventory-completeness warnings but keeps diagnostics in details", async () => {
+ vi.mocked(useShowDetails).mockReturnValue(true);
+ const user = userEvent.setup();
+ renderModal(
+ inspection({
+ warnings: [
{
- captureId: 'photoshop-preferences-installed',
- moduleId: 'apps.photoshop',
- configSetId: 'preferences',
- payloadManifest: [{ relativePath: 'prefs.psp' }],
+ code: "PARTIAL",
+ message: "Some settings could not be inventoried.",
+ impact: "inventory_incomplete",
},
+ { code: "TRACE", message: "Technical trace.", impact: "diagnostic" },
],
}),
);
-
- 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();
+ 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();
});
- it('reveals module ids and the file path only under Configuration details', async () => {
- vi.mocked(useShowDetails).mockReturnValue(true);
- await mockManifest(V1_MANIFEST);
- const user = userEvent.setup();
-
- render();
-
- await appsSection();
- expect(screen.queryByText(/notepad-plus-plus/)).not.toBeInTheDocument();
- expect(screen.queryByText(defaultProps.profilePath)).not.toBeInTheDocument();
-
- 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();
-
- 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);
-
- expect(onOpenChange).toHaveBeenCalledWith(false);
+ 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(),
+ );
});
});
diff --git a/src/components/app/intent/profile-contents-modal.tsx b/src/components/app/intent/profile-contents-modal.tsx
index 34c3161..21f08ce 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,60 +43,147 @@ 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().toLocaleLowerCase();
+ return (
+ !needle ||
+ values.some((value) => value.toLocaleLowerCase().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(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(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;
+ if (requestId.current !== currentRequest) return;
setContents(result);
+ setActiveTab(defaultTab(result));
})
.catch((err: unknown) => {
- if (cancelled) return;
+ if (requestId.current !== currentRequest) return;
setError(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 === "ArrowLeft" || event.key === "Home") nextTab = "apps";
+ if (event.key === "ArrowRight" || event.key === "End") nextTab = "settings";
+ if (!nextTab) return;
+ event.preventDefault();
+ chooseTab(nextTab, true);
+ };
const appCount = contents?.apps.length ?? 0;
- const settingsCount = contents?.settingsModuleCount ?? 0;
+ const settingsCount = contents?.settingsApps.length ?? 0;
+ const filteredApps =
+ contents?.apps.filter((app) =>
+ rowMatches(appsQuery, [app.displayName, ...app.packageRefs]),
+ ) ?? [];
+ const filteredSettings =
+ contents?.settingsApps.filter((row) =>
+ rowMatches(settingsQuery, [
+ row.displayName,
+ ...row.packageRefs,
+ ...row.moduleIds,
+ ...row.candidateAppIds,
+ ]),
+ ) ?? [];
+ const activeQuery = activeTab === "apps" ? appsQuery : settingsQuery;
return (
)}
- {error && (
+ {loadedError && (
This profile could not be read.
-
{error}
+
{loadedError}
)}
- {loadedContents && !loading && !error && (
+ {loadedContents && !loading && !loadedError && (
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 20436d5..9b47089 100644
--- a/src/components/app/intent/setup-flow-whats-inside.test.tsx
+++ b/src/components/app/intent/setup-flow-whats-inside.test.tsx
@@ -111,6 +111,27 @@ describe('SetupFlow — "What\'s inside"', () => {
},
);
+ it("isolates only activation keys and lets shortcuts bubble normally", async () => {
+ const user = userEvent.setup();
+ 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();
+
+ await user.keyboard("{Enter}");
+ expect(await screen.findByRole("dialog")).toBeVisible();
+ expect(baseProps.onPreview).not.toHaveBeenCalled();
+ });
+
it("shows the update-required state without invoking inspection on an older engine", async () => {
const user = userEvent.setup();
const onInspectProfile = vi.fn();
diff --git a/src/components/app/intent/setup-flow.tsx b/src/components/app/intent/setup-flow.tsx
index 9d3f392..b7904bc 100644
--- a/src/components/app/intent/setup-flow.tsx
+++ b/src/components/app/intent/setup-flow.tsx
@@ -1033,8 +1033,10 @@ export function SetupFlow({
onKeyDown={(e) => {
// The card is a keyboard-selectable parent. Let the
// button keep its native Enter/Space activation while
- // keeping those keys out of the card's selection handler.
- e.stopPropagation();
+ // 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"
From 5e90febd8b9705b01cfa11c61cf58a144d02be09 Mon Sep 17 00:00:00 2001
From: Artexis10
Date: Sat, 1 Aug 2026 18:28:01 +0300
Subject: [PATCH 10/14] test(setup): cover profile contents end to end
---
e2e/fixtures/README.md | 13 +
e2e/fixtures/profile_inspect.fixture.json | 827 ++++++++++++++++++
e2e/profile-contents.spec.ts | 88 ++
e2e/real-engine/helpers/bridge.ts | 116 +++
.../profile-contents-inspection.spec.ts | 82 ++
src/e2e/mock-engine.conformance.test.ts | 64 ++
src/e2e/mock-engine.ts | 23 +-
7 files changed, 1212 insertions(+), 1 deletion(-)
create mode 100644 e2e/fixtures/profile_inspect.fixture.json
create mode 100644 e2e/profile-contents.spec.ts
create mode 100644 e2e/real-engine/profile-contents-inspection.spec.ts
diff --git a/e2e/fixtures/README.md b/e2e/fixtures/README.md
index 9ca44c5..dabd1ba 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 0000000..0042d3c
--- /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:2",
+ "manifestAppId": "adobe-creative-cloud",
+ "displayName": "Adobe Creative Cloud",
+ "packageRefs": [
+ "Vendor.AdobeCreativeCloud"
+ ],
+ "hasSettings": true
+ },
+ {
+ "id": "app:cursor:3",
+ "manifestAppId": "cursor",
+ "displayName": "Cursor",
+ "packageRefs": [
+ "AnySphere.Cursor",
+ "com.endstate.hidden-package-ref"
+ ],
+ "hasSettings": true
+ },
+ {
+ "id": "app:apple-mobile-device-support:4",
+ "manifestAppId": "apple-mobile-device-support",
+ "displayName": "Apple Mobile Device Support",
+ "packageRefs": [
+ "Vendor.AppleMobileDeviceSupport"
+ ],
+ "hasSettings": true
+ },
+ {
+ "id": "app:apple-software-update:5",
+ "manifestAppId": "apple-software-update",
+ "displayName": "Apple Software Update",
+ "packageRefs": [
+ "Vendor.AppleSoftwareUpdate"
+ ],
+ "hasSettings": true
+ },
+ {
+ "id": "app:bitwarden:6",
+ "manifestAppId": "bitwarden",
+ "displayName": "Bitwarden",
+ "packageRefs": [
+ "Vendor.Bitwarden"
+ ],
+ "hasSettings": true
+ },
+ {
+ "id": "app:brave:7",
+ "manifestAppId": "brave",
+ "displayName": "Brave",
+ "packageRefs": [
+ "Vendor.Brave"
+ ],
+ "hasSettings": true
+ },
+ {
+ "id": "app:burntsushi-ripgrep-msvc:8",
+ "manifestAppId": "burntsushi-ripgrep-msvc",
+ "displayName": "ripgrep (MSVC)",
+ "packageRefs": [
+ "Vendor.BurntsushiRipgrepMsvc"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:capcut:9",
+ "manifestAppId": "capcut",
+ "displayName": "CapCut",
+ "packageRefs": [
+ "Vendor.Capcut"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:cloudflared:10",
+ "manifestAppId": "cloudflared",
+ "displayName": "cloudflared",
+ "packageRefs": [
+ "Vendor.Cloudflared"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:discord:11",
+ "manifestAppId": "discord",
+ "displayName": "Discord",
+ "packageRefs": [
+ "Vendor.Discord"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:docker-desktop:12",
+ "manifestAppId": "docker-desktop",
+ "displayName": "Docker Desktop",
+ "packageRefs": [
+ "Vendor.DockerDesktop"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:figma:13",
+ "manifestAppId": "figma",
+ "displayName": "Figma",
+ "packageRefs": [
+ "Vendor.Figma"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:firefox:14",
+ "manifestAppId": "firefox",
+ "displayName": "Firefox",
+ "packageRefs": [
+ "Vendor.Firefox"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:git:15",
+ "manifestAppId": "git",
+ "displayName": "Git",
+ "packageRefs": [
+ "Vendor.Git"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:github-desktop:16",
+ "manifestAppId": "github-desktop",
+ "displayName": "GitHub Desktop",
+ "packageRefs": [
+ "Vendor.GithubDesktop"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:google-chrome:17",
+ "manifestAppId": "google-chrome",
+ "displayName": "Google Chrome",
+ "packageRefs": [
+ "Vendor.GoogleChrome"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:handbrake:18",
+ "manifestAppId": "handbrake",
+ "displayName": "HandBrake",
+ "packageRefs": [
+ "Vendor.Handbrake"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:keepassxc:19",
+ "manifestAppId": "keepassxc",
+ "displayName": "KeePassXC",
+ "packageRefs": [
+ "Vendor.Keepassxc"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:libreoffice:20",
+ "manifestAppId": "libreoffice",
+ "displayName": "LibreOffice",
+ "packageRefs": [
+ "Vendor.Libreoffice"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:microsoft-powertoys:21",
+ "manifestAppId": "microsoft-powertoys",
+ "displayName": "Microsoft PowerToys",
+ "packageRefs": [
+ "Vendor.MicrosoftPowertoys"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:microsoft-teams:22",
+ "manifestAppId": "microsoft-teams",
+ "displayName": "Microsoft Teams",
+ "packageRefs": [
+ "Vendor.MicrosoftTeams"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:nodejs:23",
+ "manifestAppId": "nodejs",
+ "displayName": "Node.js",
+ "packageRefs": [
+ "Vendor.Nodejs"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:notion:24",
+ "manifestAppId": "notion",
+ "displayName": "Notion",
+ "packageRefs": [
+ "Vendor.Notion"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:obsidian:25",
+ "manifestAppId": "obsidian",
+ "displayName": "Obsidian",
+ "packageRefs": [
+ "Vendor.Obsidian"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:openvpn:26",
+ "manifestAppId": "openvpn",
+ "displayName": "OpenVPN",
+ "packageRefs": [
+ "Vendor.Openvpn"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:paint-net:27",
+ "manifestAppId": "paint-net",
+ "displayName": "Paint.NET",
+ "packageRefs": [
+ "Vendor.PaintNet"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:postgresql:28",
+ "manifestAppId": "postgresql",
+ "displayName": "PostgreSQL",
+ "packageRefs": [
+ "Vendor.Postgresql"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:powershell:29",
+ "manifestAppId": "powershell",
+ "displayName": "PowerShell",
+ "packageRefs": [
+ "Vendor.Powershell"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:python:30",
+ "manifestAppId": "python",
+ "displayName": "Python",
+ "packageRefs": [
+ "Vendor.Python"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:qbittorrent:31",
+ "manifestAppId": "qbittorrent",
+ "displayName": "qBittorrent",
+ "packageRefs": [
+ "Vendor.Qbittorrent"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:rustup:32",
+ "manifestAppId": "rustup",
+ "displayName": "Rustup",
+ "packageRefs": [
+ "Vendor.Rustup"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:slack:33",
+ "manifestAppId": "slack",
+ "displayName": "Slack",
+ "packageRefs": [
+ "Vendor.Slack"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:spotify:34",
+ "manifestAppId": "spotify",
+ "displayName": "Spotify",
+ "packageRefs": [
+ "Vendor.Spotify"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:steam:35",
+ "manifestAppId": "steam",
+ "displayName": "Steam",
+ "packageRefs": [
+ "Vendor.Steam"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:telegram:36",
+ "manifestAppId": "telegram",
+ "displayName": "Telegram Desktop",
+ "packageRefs": [
+ "Vendor.Telegram"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:termius:37",
+ "manifestAppId": "termius",
+ "displayName": "Termius",
+ "packageRefs": [
+ "Vendor.Termius"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:thunderbird:38",
+ "manifestAppId": "thunderbird",
+ "displayName": "Thunderbird",
+ "packageRefs": [
+ "Vendor.Thunderbird"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:transmission:39",
+ "manifestAppId": "transmission",
+ "displayName": "Transmission",
+ "packageRefs": [
+ "Vendor.Transmission"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:unified-remote:40",
+ "manifestAppId": "unified-remote",
+ "displayName": "Unified Remote",
+ "packageRefs": [
+ "Vendor.UnifiedRemote"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:vcredist:41",
+ "manifestAppId": "vcredist",
+ "displayName": "Visual C++ Redistributable",
+ "packageRefs": [
+ "Vendor.Vcredist"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:vlc:42",
+ "manifestAppId": "vlc",
+ "displayName": "VLC media player",
+ "packageRefs": [
+ "Vendor.Vlc"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:vscode:43",
+ "manifestAppId": "vscode",
+ "displayName": "Visual Studio Code",
+ "packageRefs": [
+ "Vendor.Vscode"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:warp:44",
+ "manifestAppId": "warp",
+ "displayName": "Warp",
+ "packageRefs": [
+ "Vendor.Warp"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:winscp:45",
+ "manifestAppId": "winscp",
+ "displayName": "WinSCP",
+ "packageRefs": [
+ "Vendor.Winscp"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:winfsp:46",
+ "manifestAppId": "winfsp",
+ "displayName": "WinFsp",
+ "packageRefs": [
+ "Vendor.Winfsp"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:xconvert:47",
+ "manifestAppId": "xconvert",
+ "displayName": "XnConvert",
+ "packageRefs": [
+ "Vendor.Xconvert"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:yt-dlp-ffmpeg:48",
+ "manifestAppId": "yt-dlp-ffmpeg",
+ "displayName": "yt-dlp FFmpeg",
+ "packageRefs": [
+ "Vendor.YtDlpFfmpeg"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:yt-dlp:49",
+ "manifestAppId": "yt-dlp",
+ "displayName": "yt-dlp",
+ "packageRefs": [
+ "Vendor.YtDlp"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:zoom:50",
+ "manifestAppId": "zoom",
+ "displayName": "Zoom",
+ "packageRefs": [
+ "Vendor.Zoom"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:audacity:51",
+ "manifestAppId": "audacity",
+ "displayName": "Audacity",
+ "packageRefs": [
+ "Vendor.Audacity"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:blender:52",
+ "manifestAppId": "blender",
+ "displayName": "Blender",
+ "packageRefs": [
+ "Vendor.Blender"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:calibre:53",
+ "manifestAppId": "calibre",
+ "displayName": "Calibre",
+ "packageRefs": [
+ "Vendor.Calibre"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:cygwin:54",
+ "manifestAppId": "cygwin",
+ "displayName": "Cygwin",
+ "packageRefs": [
+ "Vendor.Cygwin"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:dbeaver:55",
+ "manifestAppId": "dbeaver",
+ "displayName": "DBeaver",
+ "packageRefs": [
+ "Vendor.Dbeaver"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:ffmpeg:56",
+ "manifestAppId": "ffmpeg",
+ "displayName": "FFmpeg",
+ "packageRefs": [
+ "Vendor.Ffmpeg"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:gimp:57",
+ "manifestAppId": "gimp",
+ "displayName": "GIMP",
+ "packageRefs": [
+ "Vendor.Gimp"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:go:58",
+ "manifestAppId": "go",
+ "displayName": "Go",
+ "packageRefs": [
+ "Vendor.Go"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:inkscape:59",
+ "manifestAppId": "inkscape",
+ "displayName": "Inkscape",
+ "packageRefs": [
+ "Vendor.Inkscape"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:jdk:60",
+ "manifestAppId": "jdk",
+ "displayName": "OpenJDK",
+ "packageRefs": [
+ "Vendor.Jdk"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:kdenlive:61",
+ "manifestAppId": "kdenlive",
+ "displayName": "Kdenlive",
+ "packageRefs": [
+ "Vendor.Kdenlive"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:lm-studio:62",
+ "manifestAppId": "lm-studio",
+ "displayName": "LM Studio",
+ "packageRefs": [
+ "Vendor.LmStudio"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:mongodb-compass:63",
+ "manifestAppId": "mongodb-compass",
+ "displayName": "MongoDB Compass",
+ "packageRefs": [
+ "Vendor.MongodbCompass"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:neovim:64",
+ "manifestAppId": "neovim",
+ "displayName": "Neovim",
+ "packageRefs": [
+ "Vendor.Neovim"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:nvidia-app:65",
+ "manifestAppId": "nvidia-app",
+ "displayName": "NVIDIA App",
+ "packageRefs": [
+ "Vendor.NvidiaApp"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:ollama:66",
+ "manifestAppId": "ollama",
+ "displayName": "Ollama",
+ "packageRefs": [
+ "Vendor.Ollama"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:postman:67",
+ "manifestAppId": "postman",
+ "displayName": "Postman",
+ "packageRefs": [
+ "Vendor.Postman"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:putty:68",
+ "manifestAppId": "putty",
+ "displayName": "PuTTY",
+ "packageRefs": [
+ "Vendor.Putty"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:signal:69",
+ "manifestAppId": "signal",
+ "displayName": "Signal",
+ "packageRefs": [
+ "Vendor.Signal"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:sysinternals:70",
+ "manifestAppId": "sysinternals",
+ "displayName": "Sysinternals",
+ "packageRefs": [
+ "Vendor.Sysinternals"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:visual-studio:71",
+ "manifestAppId": "visual-studio",
+ "displayName": "Visual Studio",
+ "packageRefs": [
+ "Vendor.VisualStudio"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:windows-terminal:72",
+ "manifestAppId": "windows-terminal",
+ "displayName": "Windows Terminal",
+ "packageRefs": [
+ "Vendor.WindowsTerminal"
+ ],
+ "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": 1
+ },
+ {
+ "id": "settings:app:adobe-creative-cloud:2",
+ "displayName": "Adobe Creative Cloud settings",
+ "associationStatus": "included",
+ "ownerId": "app:adobe-creative-cloud:2",
+ "appId": "app:adobe-creative-cloud:2",
+ "appIncluded": true,
+ "packageRefs": [
+ "Vendor.AdobeCreativeCloud"
+ ],
+ "moduleIds": [
+ "apps.adobe-creative-cloud"
+ ],
+ "candidateAppIds": [
+ "app:adobe-creative-cloud:2"
+ ],
+ "capturedEntryCount": 2
+ },
+ {
+ "id": "settings:app:cursor:3",
+ "displayName": "Cursor settings",
+ "associationStatus": "included",
+ "ownerId": "app:cursor:3",
+ "appId": "app:cursor:3",
+ "appIncluded": true,
+ "packageRefs": [
+ "AnySphere.Cursor",
+ "com.endstate.hidden-package-ref"
+ ],
+ "moduleIds": [
+ "apps.cursor",
+ "apps.hidden-module-id"
+ ],
+ "candidateAppIds": [
+ "app:cursor:3"
+ ],
+ "capturedEntryCount": 3
+ },
+ {
+ "id": "settings:app:apple-mobile-device-support:4",
+ "displayName": "Apple Mobile Device Support settings",
+ "associationStatus": "included",
+ "ownerId": "app:apple-mobile-device-support:4",
+ "appId": "app:apple-mobile-device-support:4",
+ "appIncluded": true,
+ "packageRefs": [
+ "Vendor.AppleMobileDeviceSupport"
+ ],
+ "moduleIds": [
+ "apps.apple-mobile-device-support"
+ ],
+ "candidateAppIds": [
+ "app:apple-mobile-device-support:4"
+ ],
+ "capturedEntryCount": 4
+ },
+ {
+ "id": "settings:app:apple-software-update:5",
+ "displayName": "Apple Software Update settings",
+ "associationStatus": "included",
+ "ownerId": "app:apple-software-update:5",
+ "appId": "app:apple-software-update:5",
+ "appIncluded": true,
+ "packageRefs": [
+ "Vendor.AppleSoftwareUpdate"
+ ],
+ "moduleIds": [
+ "apps.apple-software-update"
+ ],
+ "candidateAppIds": [
+ "app:apple-software-update:5"
+ ],
+ "capturedEntryCount": 5
+ },
+ {
+ "id": "settings:app:bitwarden:6",
+ "displayName": "Bitwarden settings",
+ "associationStatus": "included",
+ "ownerId": "app:bitwarden:6",
+ "appId": "app:bitwarden:6",
+ "appIncluded": true,
+ "packageRefs": [
+ "Vendor.Bitwarden"
+ ],
+ "moduleIds": [
+ "apps.bitwarden"
+ ],
+ "candidateAppIds": [
+ "app:bitwarden:6"
+ ],
+ "capturedEntryCount": 6
+ },
+ {
+ "id": "settings:app:brave:7",
+ "displayName": "Brave settings",
+ "associationStatus": "included",
+ "ownerId": "app:brave:7",
+ "appId": "app:brave:7",
+ "appIncluded": true,
+ "packageRefs": [
+ "Vendor.Brave"
+ ],
+ "moduleIds": [
+ "apps.brave"
+ ],
+ "candidateAppIds": [
+ "app:brave:7"
+ ],
+ "capturedEntryCount": 7
+ },
+ {
+ "id": "settings:package:vendor.absent-settings",
+ "displayName": "Retired App settings",
+ "associationStatus": "not_in_profile",
+ "ownerId": "package:vendor.absent-settings",
+ "appId": null,
+ "appIncluded": false,
+ "packageRefs": [
+ "Vendor.AbsentSettings"
+ ],
+ "moduleIds": [
+ "apps.absent-settings"
+ ],
+ "candidateAppIds": [],
+ "capturedEntryCount": 3
+ }
+ ],
+ "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 0000000..15afa9b
--- /dev/null
+++ b/e2e/profile-contents.spec.ts
@@ -0,0 +1,88 @@
+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();
+ 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('Retired App 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();
+
+ 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('');
+
+ 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 ace849f..3356528 100644
--- a/e2e/real-engine/helpers/bridge.ts
+++ b/e2e/real-engine/helpers/bridge.ts
@@ -1,4 +1,5 @@
import type { APIRequestContext, Page } from '@playwright/test';
+import path from 'node:path';
/**
* Helpers for the real-engine lane. These talk to the standalone dev bridge
@@ -41,6 +42,45 @@ export interface SeededProfile {
name: string;
}
+export interface SeededInspectionProfile extends SeededProfile {
+ /** Every copied raw-profile file, retained for precise cleanup. */
+ copiedPaths: 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,
+ name = 'ci-profile-inspection',
+): Promise {
+ const profilesDir = await bridgeInvoke(request, 'get_default_profiles_directory');
+ const fixtureRoot = path.resolve(process.cwd(), 'tests/fixtures/profile-inspect-profile');
+ const destinationRoot = path.join(profilesDir, name);
+ const copiedPaths: string[] = [];
+
+ for (const relativePath of INSPECTION_FIXTURE_FILES) {
+ const destination = path.join(destinationRoot, relativePath);
+ await bridgeInvoke(request, 'delete_file_silent', { path: destination }).catch(() => {});
+ await bridgeInvoke(request, 'copy_file', {
+ sourcePath: path.join(fixtureRoot, relativePath),
+ destPath: destination,
+ });
+ copiedPaths.push(destination);
+ }
+
+ return { path: path.join(destinationRoot, 'manifest.jsonc'), name, copiedPaths };
+}
+
/**
* 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 +112,18 @@ 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 {
+ await Promise.all(
+ profile.copiedPaths.map((filePath) =>
+ bridgeInvoke(request, 'delete_file_silent', { path: filePath }).catch(() => {}),
+ ),
+ );
+}
+
export interface ApplyAction {
id: string;
ref: string;
@@ -89,6 +141,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 0000000..200669d
--- /dev/null
+++ b/e2e/real-engine/profile-contents-inspection.spec.ts
@@ -0,0 +1,82 @@
+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 }) => {
+ 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/src/e2e/mock-engine.conformance.test.ts b/src/e2e/mock-engine.conformance.test.ts
index 9eb5d0e..b8b00ea 100644
--- a/src/e2e/mock-engine.conformance.test.ts
+++ b/src/e2e/mock-engine.conformance.test.ts
@@ -23,8 +23,72 @@
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(data.settingsApps.map((row) => row.associationStatus)).toEqual([
+ 'included', 'included', 'included', 'included',
+ 'included', 'included', 'included', 'not_in_profile',
+ ]);
+ for (const row of data.settingsApps.slice(0, 7)) {
+ 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[data.settingsApps.length - 1]).toMatchObject({
+ associationStatus: 'not_in_profile',
+ appId: null,
+ appIncluded: false,
+ candidateAppIds: [],
+ });
+ expect(data.apps[2].packageRefs).toContain('com.endstate.hidden-package-ref');
+ expect(data.settingsApps[2].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 7102030..8a78757 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];
From 9f99888f02ca879f0a56bb85dd243dc17f8fc33c Mon Sep 17 00:00:00 2001
From: Artexis10
Date: Sat, 1 Aug 2026 18:41:58 +0300
Subject: [PATCH 11/14] test(setup): harden profile contents coverage
---
e2e/fixtures/profile_inspect.fixture.json | 644 +++++++++---------
e2e/profile-contents.spec.ts | 11 +-
e2e/real-engine/helpers/bridge.ts | 64 +-
.../profile-contents-inspection.spec.ts | 1 +
src/e2e/mock-engine.conformance.test.ts | 39 +-
5 files changed, 411 insertions(+), 348 deletions(-)
diff --git a/e2e/fixtures/profile_inspect.fixture.json b/e2e/fixtures/profile_inspect.fixture.json
index 0042d3c..ef698ea 100644
--- a/e2e/fixtures/profile_inspect.fixture.json
+++ b/e2e/fixtures/profile_inspect.fixture.json
@@ -23,7 +23,7 @@
"hasSettings": true
},
{
- "id": "app:adobe-creative-cloud:2",
+ "id": "app:adobe-creative-cloud:1",
"manifestAppId": "adobe-creative-cloud",
"displayName": "Adobe Creative Cloud",
"packageRefs": [
@@ -32,17 +32,7 @@
"hasSettings": true
},
{
- "id": "app:cursor:3",
- "manifestAppId": "cursor",
- "displayName": "Cursor",
- "packageRefs": [
- "AnySphere.Cursor",
- "com.endstate.hidden-package-ref"
- ],
- "hasSettings": true
- },
- {
- "id": "app:apple-mobile-device-support:4",
+ "id": "app:apple-mobile-device-support:1",
"manifestAppId": "apple-mobile-device-support",
"displayName": "Apple Mobile Device Support",
"packageRefs": [
@@ -51,7 +41,7 @@
"hasSettings": true
},
{
- "id": "app:apple-software-update:5",
+ "id": "app:apple-software-update:1",
"manifestAppId": "apple-software-update",
"displayName": "Apple Software Update",
"packageRefs": [
@@ -60,7 +50,16 @@
"hasSettings": true
},
{
- "id": "app:bitwarden:6",
+ "id": "app:audacity:1",
+ "manifestAppId": "audacity",
+ "displayName": "Audacity",
+ "packageRefs": [
+ "Vendor.Audacity"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:bitwarden:1",
"manifestAppId": "bitwarden",
"displayName": "Bitwarden",
"packageRefs": [
@@ -69,7 +68,16 @@
"hasSettings": true
},
{
- "id": "app:brave:7",
+ "id": "app:blender:1",
+ "manifestAppId": "blender",
+ "displayName": "Blender",
+ "packageRefs": [
+ "Vendor.Blender"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:brave:1",
"manifestAppId": "brave",
"displayName": "Brave",
"packageRefs": [
@@ -78,16 +86,16 @@
"hasSettings": true
},
{
- "id": "app:burntsushi-ripgrep-msvc:8",
- "manifestAppId": "burntsushi-ripgrep-msvc",
- "displayName": "ripgrep (MSVC)",
+ "id": "app:calibre:1",
+ "manifestAppId": "calibre",
+ "displayName": "Calibre",
"packageRefs": [
- "Vendor.BurntsushiRipgrepMsvc"
+ "Vendor.Calibre"
],
"hasSettings": false
},
{
- "id": "app:capcut:9",
+ "id": "app:capcut:1",
"manifestAppId": "capcut",
"displayName": "CapCut",
"packageRefs": [
@@ -96,7 +104,7 @@
"hasSettings": false
},
{
- "id": "app:cloudflared:10",
+ "id": "app:cloudflared:1",
"manifestAppId": "cloudflared",
"displayName": "cloudflared",
"packageRefs": [
@@ -105,7 +113,35 @@
"hasSettings": false
},
{
- "id": "app:discord:11",
+ "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": [
@@ -114,7 +150,7 @@
"hasSettings": false
},
{
- "id": "app:docker-desktop:12",
+ "id": "app:docker-desktop:1",
"manifestAppId": "docker-desktop",
"displayName": "Docker Desktop",
"packageRefs": [
@@ -123,7 +159,16 @@
"hasSettings": false
},
{
- "id": "app:figma:13",
+ "id": "app:ffmpeg:1",
+ "manifestAppId": "ffmpeg",
+ "displayName": "FFmpeg",
+ "packageRefs": [
+ "Vendor.Ffmpeg"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:figma:1",
"manifestAppId": "figma",
"displayName": "Figma",
"packageRefs": [
@@ -132,7 +177,7 @@
"hasSettings": false
},
{
- "id": "app:firefox:14",
+ "id": "app:firefox:1",
"manifestAppId": "firefox",
"displayName": "Firefox",
"packageRefs": [
@@ -141,7 +186,16 @@
"hasSettings": false
},
{
- "id": "app:git:15",
+ "id": "app:gimp:1",
+ "manifestAppId": "gimp",
+ "displayName": "GIMP",
+ "packageRefs": [
+ "Vendor.Gimp"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:git:1",
"manifestAppId": "git",
"displayName": "Git",
"packageRefs": [
@@ -150,7 +204,7 @@
"hasSettings": false
},
{
- "id": "app:github-desktop:16",
+ "id": "app:github-desktop:1",
"manifestAppId": "github-desktop",
"displayName": "GitHub Desktop",
"packageRefs": [
@@ -159,7 +213,16 @@
"hasSettings": false
},
{
- "id": "app:google-chrome:17",
+ "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": [
@@ -168,7 +231,7 @@
"hasSettings": false
},
{
- "id": "app:handbrake:18",
+ "id": "app:handbrake:1",
"manifestAppId": "handbrake",
"displayName": "HandBrake",
"packageRefs": [
@@ -177,7 +240,25 @@
"hasSettings": false
},
{
- "id": "app:keepassxc:19",
+ "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": [
@@ -186,7 +267,7 @@
"hasSettings": false
},
{
- "id": "app:libreoffice:20",
+ "id": "app:libreoffice:1",
"manifestAppId": "libreoffice",
"displayName": "LibreOffice",
"packageRefs": [
@@ -195,7 +276,16 @@
"hasSettings": false
},
{
- "id": "app:microsoft-powertoys:21",
+ "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": [
@@ -204,7 +294,7 @@
"hasSettings": false
},
{
- "id": "app:microsoft-teams:22",
+ "id": "app:microsoft-teams:1",
"manifestAppId": "microsoft-teams",
"displayName": "Microsoft Teams",
"packageRefs": [
@@ -213,7 +303,25 @@
"hasSettings": false
},
{
- "id": "app:nodejs:23",
+ "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": [
@@ -222,7 +330,7 @@
"hasSettings": false
},
{
- "id": "app:notion:24",
+ "id": "app:notion:1",
"manifestAppId": "notion",
"displayName": "Notion",
"packageRefs": [
@@ -231,7 +339,16 @@
"hasSettings": false
},
{
- "id": "app:obsidian:25",
+ "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": [
@@ -240,7 +357,25 @@
"hasSettings": false
},
{
- "id": "app:openvpn:26",
+ "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": [
@@ -249,7 +384,7 @@
"hasSettings": false
},
{
- "id": "app:paint-net:27",
+ "id": "app:paint-net:1",
"manifestAppId": "paint-net",
"displayName": "Paint.NET",
"packageRefs": [
@@ -258,7 +393,7 @@
"hasSettings": false
},
{
- "id": "app:postgresql:28",
+ "id": "app:postgresql:1",
"manifestAppId": "postgresql",
"displayName": "PostgreSQL",
"packageRefs": [
@@ -267,7 +402,16 @@
"hasSettings": false
},
{
- "id": "app:powershell:29",
+ "id": "app:postman:1",
+ "manifestAppId": "postman",
+ "displayName": "Postman",
+ "packageRefs": [
+ "Vendor.Postman"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:powershell:1",
"manifestAppId": "powershell",
"displayName": "PowerShell",
"packageRefs": [
@@ -276,7 +420,16 @@
"hasSettings": false
},
{
- "id": "app:python:30",
+ "id": "app:putty:1",
+ "manifestAppId": "putty",
+ "displayName": "PuTTY",
+ "packageRefs": [
+ "Vendor.Putty"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:python:1",
"manifestAppId": "python",
"displayName": "Python",
"packageRefs": [
@@ -285,7 +438,7 @@
"hasSettings": false
},
{
- "id": "app:qbittorrent:31",
+ "id": "app:qbittorrent:1",
"manifestAppId": "qbittorrent",
"displayName": "qBittorrent",
"packageRefs": [
@@ -294,7 +447,16 @@
"hasSettings": false
},
{
- "id": "app:rustup:32",
+ "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": [
@@ -303,7 +465,16 @@
"hasSettings": false
},
{
- "id": "app:slack:33",
+ "id": "app:signal:1",
+ "manifestAppId": "signal",
+ "displayName": "Signal",
+ "packageRefs": [
+ "Vendor.Signal"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:slack:1",
"manifestAppId": "slack",
"displayName": "Slack",
"packageRefs": [
@@ -312,7 +483,7 @@
"hasSettings": false
},
{
- "id": "app:spotify:34",
+ "id": "app:spotify:1",
"manifestAppId": "spotify",
"displayName": "Spotify",
"packageRefs": [
@@ -321,7 +492,7 @@
"hasSettings": false
},
{
- "id": "app:steam:35",
+ "id": "app:steam:1",
"manifestAppId": "steam",
"displayName": "Steam",
"packageRefs": [
@@ -330,7 +501,16 @@
"hasSettings": false
},
{
- "id": "app:telegram:36",
+ "id": "app:sysinternals:1",
+ "manifestAppId": "sysinternals",
+ "displayName": "Sysinternals",
+ "packageRefs": [
+ "Vendor.Sysinternals"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:telegram:1",
"manifestAppId": "telegram",
"displayName": "Telegram Desktop",
"packageRefs": [
@@ -339,7 +519,7 @@
"hasSettings": false
},
{
- "id": "app:termius:37",
+ "id": "app:termius:1",
"manifestAppId": "termius",
"displayName": "Termius",
"packageRefs": [
@@ -348,7 +528,7 @@
"hasSettings": false
},
{
- "id": "app:thunderbird:38",
+ "id": "app:thunderbird:1",
"manifestAppId": "thunderbird",
"displayName": "Thunderbird",
"packageRefs": [
@@ -357,7 +537,7 @@
"hasSettings": false
},
{
- "id": "app:transmission:39",
+ "id": "app:transmission:1",
"manifestAppId": "transmission",
"displayName": "Transmission",
"packageRefs": [
@@ -366,7 +546,7 @@
"hasSettings": false
},
{
- "id": "app:unified-remote:40",
+ "id": "app:unified-remote:1",
"manifestAppId": "unified-remote",
"displayName": "Unified Remote",
"packageRefs": [
@@ -375,7 +555,7 @@
"hasSettings": false
},
{
- "id": "app:vcredist:41",
+ "id": "app:vcredist:1",
"manifestAppId": "vcredist",
"displayName": "Visual C++ Redistributable",
"packageRefs": [
@@ -384,16 +564,16 @@
"hasSettings": false
},
{
- "id": "app:vlc:42",
- "manifestAppId": "vlc",
- "displayName": "VLC media player",
+ "id": "app:visual-studio:1",
+ "manifestAppId": "visual-studio",
+ "displayName": "Visual Studio",
"packageRefs": [
- "Vendor.Vlc"
+ "Vendor.VisualStudio"
],
"hasSettings": false
},
{
- "id": "app:vscode:43",
+ "id": "app:vscode:1",
"manifestAppId": "vscode",
"displayName": "Visual Studio Code",
"packageRefs": [
@@ -402,7 +582,16 @@
"hasSettings": false
},
{
- "id": "app:warp:44",
+ "id": "app:vlc:1",
+ "manifestAppId": "vlc",
+ "displayName": "VLC media player",
+ "packageRefs": [
+ "Vendor.Vlc"
+ ],
+ "hasSettings": false
+ },
+ {
+ "id": "app:warp:1",
"manifestAppId": "warp",
"displayName": "Warp",
"packageRefs": [
@@ -411,16 +600,16 @@
"hasSettings": false
},
{
- "id": "app:winscp:45",
- "manifestAppId": "winscp",
- "displayName": "WinSCP",
+ "id": "app:windows-terminal:1",
+ "manifestAppId": "windows-terminal",
+ "displayName": "Windows Terminal",
"packageRefs": [
- "Vendor.Winscp"
+ "Vendor.WindowsTerminal"
],
"hasSettings": false
},
{
- "id": "app:winfsp:46",
+ "id": "app:winfsp:1",
"manifestAppId": "winfsp",
"displayName": "WinFsp",
"packageRefs": [
@@ -429,25 +618,25 @@
"hasSettings": false
},
{
- "id": "app:xconvert:47",
- "manifestAppId": "xconvert",
- "displayName": "XnConvert",
+ "id": "app:winscp:1",
+ "manifestAppId": "winscp",
+ "displayName": "WinSCP",
"packageRefs": [
- "Vendor.Xconvert"
+ "Vendor.Winscp"
],
"hasSettings": false
},
{
- "id": "app:yt-dlp-ffmpeg:48",
- "manifestAppId": "yt-dlp-ffmpeg",
- "displayName": "yt-dlp FFmpeg",
+ "id": "app:xconvert:1",
+ "manifestAppId": "xconvert",
+ "displayName": "XnConvert",
"packageRefs": [
- "Vendor.YtDlpFfmpeg"
+ "Vendor.Xconvert"
],
"hasSettings": false
},
{
- "id": "app:yt-dlp:49",
+ "id": "app:yt-dlp:1",
"manifestAppId": "yt-dlp",
"displayName": "yt-dlp",
"packageRefs": [
@@ -456,209 +645,20 @@
"hasSettings": false
},
{
- "id": "app:zoom:50",
- "manifestAppId": "zoom",
- "displayName": "Zoom",
- "packageRefs": [
- "Vendor.Zoom"
- ],
- "hasSettings": false
- },
- {
- "id": "app:audacity:51",
- "manifestAppId": "audacity",
- "displayName": "Audacity",
- "packageRefs": [
- "Vendor.Audacity"
- ],
- "hasSettings": false
- },
- {
- "id": "app:blender:52",
- "manifestAppId": "blender",
- "displayName": "Blender",
- "packageRefs": [
- "Vendor.Blender"
- ],
- "hasSettings": false
- },
- {
- "id": "app:calibre:53",
- "manifestAppId": "calibre",
- "displayName": "Calibre",
- "packageRefs": [
- "Vendor.Calibre"
- ],
- "hasSettings": false
- },
- {
- "id": "app:cygwin:54",
- "manifestAppId": "cygwin",
- "displayName": "Cygwin",
- "packageRefs": [
- "Vendor.Cygwin"
- ],
- "hasSettings": false
- },
- {
- "id": "app:dbeaver:55",
- "manifestAppId": "dbeaver",
- "displayName": "DBeaver",
- "packageRefs": [
- "Vendor.Dbeaver"
- ],
- "hasSettings": false
- },
- {
- "id": "app:ffmpeg:56",
- "manifestAppId": "ffmpeg",
- "displayName": "FFmpeg",
- "packageRefs": [
- "Vendor.Ffmpeg"
- ],
- "hasSettings": false
- },
- {
- "id": "app:gimp:57",
- "manifestAppId": "gimp",
- "displayName": "GIMP",
- "packageRefs": [
- "Vendor.Gimp"
- ],
- "hasSettings": false
- },
- {
- "id": "app:go:58",
- "manifestAppId": "go",
- "displayName": "Go",
- "packageRefs": [
- "Vendor.Go"
- ],
- "hasSettings": false
- },
- {
- "id": "app:inkscape:59",
- "manifestAppId": "inkscape",
- "displayName": "Inkscape",
- "packageRefs": [
- "Vendor.Inkscape"
- ],
- "hasSettings": false
- },
- {
- "id": "app:jdk:60",
- "manifestAppId": "jdk",
- "displayName": "OpenJDK",
- "packageRefs": [
- "Vendor.Jdk"
- ],
- "hasSettings": false
- },
- {
- "id": "app:kdenlive:61",
- "manifestAppId": "kdenlive",
- "displayName": "Kdenlive",
- "packageRefs": [
- "Vendor.Kdenlive"
- ],
- "hasSettings": false
- },
- {
- "id": "app:lm-studio:62",
- "manifestAppId": "lm-studio",
- "displayName": "LM Studio",
- "packageRefs": [
- "Vendor.LmStudio"
- ],
- "hasSettings": false
- },
- {
- "id": "app:mongodb-compass:63",
- "manifestAppId": "mongodb-compass",
- "displayName": "MongoDB Compass",
- "packageRefs": [
- "Vendor.MongodbCompass"
- ],
- "hasSettings": false
- },
- {
- "id": "app:neovim:64",
- "manifestAppId": "neovim",
- "displayName": "Neovim",
- "packageRefs": [
- "Vendor.Neovim"
- ],
- "hasSettings": false
- },
- {
- "id": "app:nvidia-app:65",
- "manifestAppId": "nvidia-app",
- "displayName": "NVIDIA App",
- "packageRefs": [
- "Vendor.NvidiaApp"
- ],
- "hasSettings": false
- },
- {
- "id": "app:ollama:66",
- "manifestAppId": "ollama",
- "displayName": "Ollama",
- "packageRefs": [
- "Vendor.Ollama"
- ],
- "hasSettings": false
- },
- {
- "id": "app:postman:67",
- "manifestAppId": "postman",
- "displayName": "Postman",
- "packageRefs": [
- "Vendor.Postman"
- ],
- "hasSettings": false
- },
- {
- "id": "app:putty:68",
- "manifestAppId": "putty",
- "displayName": "PuTTY",
- "packageRefs": [
- "Vendor.Putty"
- ],
- "hasSettings": false
- },
- {
- "id": "app:signal:69",
- "manifestAppId": "signal",
- "displayName": "Signal",
- "packageRefs": [
- "Vendor.Signal"
- ],
- "hasSettings": false
- },
- {
- "id": "app:sysinternals:70",
- "manifestAppId": "sysinternals",
- "displayName": "Sysinternals",
- "packageRefs": [
- "Vendor.Sysinternals"
- ],
- "hasSettings": false
- },
- {
- "id": "app:visual-studio:71",
- "manifestAppId": "visual-studio",
- "displayName": "Visual Studio",
+ "id": "app:yt-dlp-ffmpeg:1",
+ "manifestAppId": "yt-dlp-ffmpeg",
+ "displayName": "yt-dlp FFmpeg",
"packageRefs": [
- "Vendor.VisualStudio"
+ "Vendor.YtDlpFfmpeg"
],
"hasSettings": false
},
{
- "id": "app:windows-terminal:72",
- "manifestAppId": "windows-terminal",
- "displayName": "Windows Terminal",
+ "id": "app:zoom:1",
+ "manifestAppId": "zoom",
+ "displayName": "Zoom",
"packageRefs": [
- "Vendor.WindowsTerminal"
+ "Vendor.Zoom"
],
"hasSettings": false
}
@@ -680,52 +680,48 @@
"candidateAppIds": [
"app:7zip:1"
],
- "capturedEntryCount": 1
+ "capturedEntryCount": 0
},
{
- "id": "settings:app:adobe-creative-cloud:2",
- "displayName": "Adobe Creative Cloud settings",
- "associationStatus": "included",
- "ownerId": "app:adobe-creative-cloud:2",
- "appId": "app:adobe-creative-cloud:2",
- "appIncluded": true,
+ "id": "settings:package:vendor.absent",
+ "displayName": "Absent settings",
+ "associationStatus": "not_in_profile",
+ "ownerId": "package:vendor.absent",
+ "appId": null,
+ "appIncluded": false,
"packageRefs": [
- "Vendor.AdobeCreativeCloud"
+ "Vendor.Absent"
],
"moduleIds": [
- "apps.adobe-creative-cloud"
- ],
- "candidateAppIds": [
- "app:adobe-creative-cloud:2"
+ "apps.absent"
],
- "capturedEntryCount": 2
+ "candidateAppIds": [],
+ "capturedEntryCount": 0
},
{
- "id": "settings:app:cursor:3",
- "displayName": "Cursor settings",
+ "id": "settings:app:adobe-creative-cloud:1",
+ "displayName": "Adobe Creative Cloud settings",
"associationStatus": "included",
- "ownerId": "app:cursor:3",
- "appId": "app:cursor:3",
+ "ownerId": "app:adobe-creative-cloud:1",
+ "appId": "app:adobe-creative-cloud:1",
"appIncluded": true,
"packageRefs": [
- "AnySphere.Cursor",
- "com.endstate.hidden-package-ref"
+ "Vendor.AdobeCreativeCloud"
],
"moduleIds": [
- "apps.cursor",
- "apps.hidden-module-id"
+ "apps.adobe-creative-cloud"
],
"candidateAppIds": [
- "app:cursor:3"
+ "app:adobe-creative-cloud:1"
],
- "capturedEntryCount": 3
+ "capturedEntryCount": 0
},
{
- "id": "settings:app:apple-mobile-device-support:4",
+ "id": "settings:app:apple-mobile-device-support:1",
"displayName": "Apple Mobile Device Support settings",
"associationStatus": "included",
- "ownerId": "app:apple-mobile-device-support:4",
- "appId": "app:apple-mobile-device-support:4",
+ "ownerId": "app:apple-mobile-device-support:1",
+ "appId": "app:apple-mobile-device-support:1",
"appIncluded": true,
"packageRefs": [
"Vendor.AppleMobileDeviceSupport"
@@ -734,16 +730,16 @@
"apps.apple-mobile-device-support"
],
"candidateAppIds": [
- "app:apple-mobile-device-support:4"
+ "app:apple-mobile-device-support:1"
],
- "capturedEntryCount": 4
+ "capturedEntryCount": 0
},
{
- "id": "settings:app:apple-software-update:5",
+ "id": "settings:app:apple-software-update:1",
"displayName": "Apple Software Update settings",
"associationStatus": "included",
- "ownerId": "app:apple-software-update:5",
- "appId": "app:apple-software-update:5",
+ "ownerId": "app:apple-software-update:1",
+ "appId": "app:apple-software-update:1",
"appIncluded": true,
"packageRefs": [
"Vendor.AppleSoftwareUpdate"
@@ -752,16 +748,16 @@
"apps.apple-software-update"
],
"candidateAppIds": [
- "app:apple-software-update:5"
+ "app:apple-software-update:1"
],
- "capturedEntryCount": 5
+ "capturedEntryCount": 0
},
{
- "id": "settings:app:bitwarden:6",
+ "id": "settings:app:bitwarden:1",
"displayName": "Bitwarden settings",
"associationStatus": "included",
- "ownerId": "app:bitwarden:6",
- "appId": "app:bitwarden:6",
+ "ownerId": "app:bitwarden:1",
+ "appId": "app:bitwarden:1",
"appIncluded": true,
"packageRefs": [
"Vendor.Bitwarden"
@@ -770,16 +766,16 @@
"apps.bitwarden"
],
"candidateAppIds": [
- "app:bitwarden:6"
+ "app:bitwarden:1"
],
- "capturedEntryCount": 6
+ "capturedEntryCount": 0
},
{
- "id": "settings:app:brave:7",
+ "id": "settings:app:brave:1",
"displayName": "Brave settings",
"associationStatus": "included",
- "ownerId": "app:brave:7",
- "appId": "app:brave:7",
+ "ownerId": "app:brave:1",
+ "appId": "app:brave:1",
"appIncluded": true,
"packageRefs": [
"Vendor.Brave"
@@ -788,25 +784,29 @@
"apps.brave"
],
"candidateAppIds": [
- "app:brave:7"
+ "app:brave:1"
],
- "capturedEntryCount": 7
+ "capturedEntryCount": 0
},
{
- "id": "settings:package:vendor.absent-settings",
- "displayName": "Retired App settings",
- "associationStatus": "not_in_profile",
- "ownerId": "package:vendor.absent-settings",
- "appId": null,
- "appIncluded": false,
+ "id": "settings:app:cursor:1",
+ "displayName": "Cursor settings",
+ "associationStatus": "included",
+ "ownerId": "app:cursor:1",
+ "appId": "app:cursor:1",
+ "appIncluded": true,
"packageRefs": [
- "Vendor.AbsentSettings"
+ "AnySphere.Cursor",
+ "com.endstate.hidden-package-ref"
],
"moduleIds": [
- "apps.absent-settings"
+ "apps.cursor",
+ "apps.hidden-module-id"
],
- "candidateAppIds": [],
- "capturedEntryCount": 3
+ "candidateAppIds": [
+ "app:cursor:1"
+ ],
+ "capturedEntryCount": 0
}
],
"warnings": [
diff --git a/e2e/profile-contents.spec.ts b/e2e/profile-contents.spec.ts
index 15afa9b..72d0ba7 100644
--- a/e2e/profile-contents.spec.ts
+++ b/e2e/profile-contents.spec.ts
@@ -51,6 +51,8 @@ test.describe("profile contents inspection", () => {
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();
@@ -66,7 +68,7 @@ test.describe("profile contents inspection", () => {
await expect(settingsSearch).toHaveValue('hidden-module-id');
await settingsSearch.fill('');
await expect(settingsPanel.getByRole('listitem')).toHaveCount(8);
- await expect(dialog.getByText('Retired App settings', { exact: true })).toBeVisible();
+ 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);
@@ -74,9 +76,16 @@ test.describe("profile contents inspection", () => {
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] });
diff --git a/e2e/real-engine/helpers/bridge.ts b/e2e/real-engine/helpers/bridge.ts
index 3356528..c5d3b48 100644
--- a/e2e/real-engine/helpers/bridge.ts
+++ b/e2e/real-engine/helpers/bridge.ts
@@ -1,4 +1,7 @@
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';
/**
@@ -45,6 +48,10 @@ export interface SeededProfile {
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 = [
@@ -61,24 +68,39 @@ const INSPECTION_FIXTURE_FILES = [
*/
export async function seedInspectionProfile(
request: APIRequestContext,
- name = 'ci-profile-inspection',
): Promise {
const profilesDir = await bridgeInvoke(request, 'get_default_profiles_directory');
const fixtureRoot = path.resolve(process.cwd(), 'tests/fixtures/profile-inspect-profile');
- const destinationRoot = path.join(profilesDir, name);
- const copiedPaths: string[] = [];
-
- for (const relativePath of INSPECTION_FIXTURE_FILES) {
- const destination = path.join(destinationRoot, relativePath);
- await bridgeInvoke(request, 'delete_file_silent', { path: destination }).catch(() => {});
- await bridgeInvoke(request, 'copy_file', {
- sourcePath: path.join(fixtureRoot, relativePath),
- destPath: destination,
- });
- copiedPaths.push(destination);
+ 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}`);
}
- return { path: path.join(destinationRoot, 'manifest.jsonc'), name, copiedPaths };
+ 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;
}
/**
@@ -117,11 +139,19 @@ export async function removeInspectionProfile(
request: APIRequestContext,
profile: SeededInspectionProfile,
): Promise {
- await Promise.all(
- profile.copiedPaths.map((filePath) =>
- bridgeInvoke(request, 'delete_file_silent', { path: filePath }).catch(() => {}),
- ),
+ 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 {
diff --git a/e2e/real-engine/profile-contents-inspection.spec.ts b/e2e/real-engine/profile-contents-inspection.spec.ts
index 200669d..f1dcecc 100644
--- a/e2e/real-engine/profile-contents-inspection.spec.ts
+++ b/e2e/real-engine/profile-contents-inspection.spec.ts
@@ -22,6 +22,7 @@ test.describe('real-engine profile contents inspection', () => {
});
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) => {
diff --git a/src/e2e/mock-engine.conformance.test.ts b/src/e2e/mock-engine.conformance.test.ts
index b8b00ea..e84a0f8 100644
--- a/src/e2e/mock-engine.conformance.test.ts
+++ b/src/e2e/mock-engine.conformance.test.ts
@@ -66,25 +66,48 @@ describe('mock engine conforms to the real profile inspection envelope', () => {
unidentifiedSettingsRowCount: 0,
});
expect(data.apps.filter((app) => app.hasSettings)).toHaveLength(7);
- expect(data.settingsApps.map((row) => row.associationStatus)).toEqual([
- 'included', 'included', 'included', 'included',
- 'included', 'included', 'included', 'not_in_profile',
- ]);
- for (const row of data.settingsApps.slice(0, 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[data.settingsApps.length - 1]).toMatchObject({
+ 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.apps[2].packageRefs).toContain('com.endstate.hidden-package-ref');
- expect(data.settingsApps[2].moduleIds).toContain('apps.hidden-module-id');
+ 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' }));
});
});
From 2f235597536dddd64cc20519338336bc6b34fa0d Mon Sep 17 00:00:00 2001
From: Artexis10
Date: Sat, 1 Aug 2026 18:56:44 +0300
Subject: [PATCH 12/14] fix(setup): make profile search locale independent
---
.../intent/profile-contents-modal.test.tsx | 42 ++++++++++++++++++-
.../app/intent/profile-contents-modal.tsx | 4 +-
2 files changed, 43 insertions(+), 3 deletions(-)
diff --git a/src/components/app/intent/profile-contents-modal.test.tsx b/src/components/app/intent/profile-contents-modal.test.tsx
index cf1e2a4..5b5e7de 100644
--- a/src/components/app/intent/profile-contents-modal.test.tsx
+++ b/src/components/app/intent/profile-contents-modal.test.tsx
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
+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";
@@ -116,6 +116,10 @@ describe("ProfileContentsModal", () => {
vi.mocked(useShowDetails).mockReturnValue(false);
});
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
it("renders semantic Apps and App settings totals from the engine result", async () => {
renderModal();
@@ -262,6 +266,42 @@ describe("ProfileContentsModal", () => {
);
});
+ 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" });
+
+ await user.type(
+ screen.getByRole("searchbox", { name: "Search apps" }),
+ "irfanview",
+ );
+
+ expect(screen.getByText("IrfanView")).toBeVisible();
+ });
+
it("shows calm no-results copy without changing totals", async () => {
const user = userEvent.setup();
renderModal();
diff --git a/src/components/app/intent/profile-contents-modal.tsx b/src/components/app/intent/profile-contents-modal.tsx
index 661986f..98227e9 100644
--- a/src/components/app/intent/profile-contents-modal.tsx
+++ b/src/components/app/intent/profile-contents-modal.tsx
@@ -50,10 +50,10 @@ function defaultTab(contents: ProfileInspectionData): ProfileContentsTab {
}
function rowMatches(query: string, values: string[]): boolean {
- const needle = query.trim().toLocaleLowerCase();
+ const needle = query.trim().toLowerCase();
return (
!needle ||
- values.some((value) => value.toLocaleLowerCase().includes(needle))
+ values.some((value) => value.toLowerCase().includes(needle))
);
}
From 1f695a2fa733d944bf8f8fdb60f3a880c3e2705e Mon Sep 17 00:00:00 2001
From: Artexis10
Date: Sat, 1 Aug 2026 18:56:52 +0300
Subject: [PATCH 13/14] fix(profile): bind inspection identity
---
src/lib/profile-contents.test.ts | 51 ++++++++++++++++++++++++++++++++
src/lib/profile-contents.ts | 35 ++++++++++++++++++----
tests/contract.test.js | 18 +++++++++--
3 files changed, 96 insertions(+), 8 deletions(-)
diff --git a/src/lib/profile-contents.test.ts b/src/lib/profile-contents.test.ts
index f5b8b37..02b89c9 100644
--- a/src/lib/profile-contents.test.ts
+++ b/src/lib/profile-contents.test.ts
@@ -135,6 +135,34 @@ describe("inspectProfileContents", () => {
expect(contents.apps.map((app) => app.displayName)).toEqual(["One", "Two"]);
});
+ 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);
+
+ await expect(
+ inspectProfileContents(SETTINGS, "C:\\Profiles\\example\\manifest.jsonc"),
+ ).rejects.toThrow(/incompatible profile inspection response/i);
+ });
+
+ 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" },
+ });
+ });
+
+ 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);
+
+ await expect(
+ inspectProfileContents(SETTINGS, "\\server\\share\\manifest.jsonc"),
+ ).rejects.toThrow(/incompatible profile inspection response/i);
+ });
+
it.each([
[
"non-1.x schema",
@@ -300,6 +328,29 @@ describe("inspectProfileContents", () => {
).rejects.toThrow(/incompatible profile inspection response/i);
});
+ 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("surfaces the engine structured failure without attempting a local fallback", async () => {
const { runEndstateOnce } = await import("./engine-exec");
vi.mocked(runEndstateOnce).mockResolvedValue({
diff --git a/src/lib/profile-contents.ts b/src/lib/profile-contents.ts
index 602b633..4d7b5a5 100644
--- a/src/lib/profile-contents.ts
+++ b/src/lib/profile-contents.ts
@@ -110,16 +110,37 @@ function incompatibleInspectionResponse(): ProfileInspectionError {
});
}
-function validateInspectionRelations(data: ProfileInspectionData): void {
+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);
+}
+
+function validateInspectionRelations(
+ data: ProfileInspectionData,
+ manifestPath: string,
+): void {
+ if (!sameWindowsPath(data.profile.manifestPath, manifestPath)) {
+ throw incompatibleInspectionResponse();
+ }
+
const appsById = new Map(data.apps.map((app) => [app.id, app]));
if (appsById.size !== data.apps.length)
throw incompatibleInspectionResponse();
+ const settingsRowIds = new Set();
const includedAppIds = new Set();
- let verifiedSettingsAppCount = 0;
+ const verifiedOwnerIds = new Set();
let unidentifiedSettingsRowCount = 0;
for (const row of data.settingsApps) {
+ if (settingsRowIds.has(row.id)) throw incompatibleInspectionResponse();
+ settingsRowIds.add(row.id);
+
if (
!row.candidateAppIds.every((candidateId) => appsById.has(candidateId))
) {
@@ -139,7 +160,8 @@ function validateInspectionRelations(data: ProfileInspectionData): void {
throw incompatibleInspectionResponse();
}
includedAppIds.add(row.appId);
- verifiedSettingsAppCount += 1;
+ if (verifiedOwnerIds.has(row.ownerId)) throw incompatibleInspectionResponse();
+ verifiedOwnerIds.add(row.ownerId);
break;
case "not_in_profile":
if (
@@ -150,7 +172,8 @@ function validateInspectionRelations(data: ProfileInspectionData): void {
) {
throw incompatibleInspectionResponse();
}
- verifiedSettingsAppCount += 1;
+ if (verifiedOwnerIds.has(row.ownerId)) throw incompatibleInspectionResponse();
+ verifiedOwnerIds.add(row.ownerId);
break;
case "ambiguous":
if (
@@ -185,7 +208,7 @@ function validateInspectionRelations(data: ProfileInspectionData): void {
if (
data.summary.appCount !== data.apps.length ||
data.summary.settingsRowCount !== data.settingsApps.length ||
- data.summary.verifiedSettingsAppCount !== verifiedSettingsAppCount ||
+ data.summary.verifiedSettingsAppCount !== verifiedOwnerIds.size ||
data.summary.unidentifiedSettingsRowCount !== unidentifiedSettingsRowCount
) {
throw incompatibleInspectionResponse();
@@ -221,6 +244,6 @@ export async function inspectProfileContents(
const parsed = inspectionEnvelopeSchema.safeParse(result.envelope);
if (!parsed.success) throw incompatibleInspectionResponse();
const data = parsed.data.data as ProfileInspectionData;
- validateInspectionRelations(data);
+ validateInspectionRelations(data, manifestPath);
return data;
}
diff --git a/tests/contract.test.js b/tests/contract.test.js
index 4efed96..4095f1b 100644
--- a/tests/contract.test.js
+++ b/tests/contract.test.js
@@ -244,6 +244,8 @@ async function testProfileInspectionAndRegenerateGolden() {
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,
@@ -255,6 +257,10 @@ async function testProfileInspectionAndRegenerateGolden() {
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';
@@ -266,6 +272,12 @@ async function testProfileInspectionAndRegenerateGolden() {
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}`);
}
@@ -284,8 +296,10 @@ async function testProfileInspectionAndRegenerateGolden() {
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 = data.settingsApps.filter((row) => row.associationStatus === 'included' || row.associationStatus === 'not_in_profile').length;
- const unidentified = data.settingsApps.length - verified;
+ 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)}`);
}
From 767eeffba3af18ab2b5cf43a62e1b30c6b30a136 Mon Sep 17 00:00:00 2001
From: Artexis10
Date: Sat, 1 Aug 2026 18:59:09 +0300
Subject: [PATCH 14/14] docs(openspec): record profile contents verification
---
.../fix-profile-contents-accuracy/tasks.md | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/openspec/changes/fix-profile-contents-accuracy/tasks.md b/openspec/changes/fix-profile-contents-accuracy/tasks.md
index 3791b40..ec306b1 100644
--- a/openspec/changes/fix-profile-contents-accuracy/tasks.md
+++ b/openspec/changes/fix-profile-contents-accuracy/tasks.md
@@ -7,18 +7,18 @@
## 2. Implement Read-Only Engine Inspection
-- [ ] 2.1 Add failing engine tests for command routing, capability advertisement, non-null deterministic output, and structured error envelopes.
-- [ ] 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.
-- [ ] 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.
-- [ ] 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.
-- [ ] 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.
+- [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.
-- [ ] 3.4 Update the bundled-engine revision/resources and contract fixtures to the reviewed engine build.
+- [x] 3.4 Update the bundled-engine revision/resources and contract fixtures to the reviewed engine build.
## 4. Build The Two-Tab Modal
@@ -30,8 +30,8 @@
## 5. Verify And Release The GUI Fix
-- [ ] 5.1 Run the targeted profile-content/component/Setup tests, TypeScript check, production build, contract tests, and strict OpenSpec validation.
-- [ ] 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.
-- [ ] 5.3 Run the project shadow check and independent code/security review; address every correctness or contract finding.
+- [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.