Installation

Install with CLI Recommended
gh skills-hub install microsoft-store-publisher

Don't have the extension? Run gh extension install samueltauil/skills-hub first.

Download and extract to your repository:

.github/skills/microsoft-store-publisher/

Extract the ZIP to .github/skills/ in your repo. The folder name must match microsoft-store-publisher for Copilot to auto-discover it.

Skill Files (13)

SKILL.md 14.9 KB
---
name: "microsoft-store-publisher"
description: "Prepare, validate, submit, and monitor Windows desktop apps in Microsoft Partner Center. Use for MSIX packaging, Store identity, listings, WACK, Partner Center automation, certification, and publishing."
---

# Microsoft Store Publisher

Use this skill when the user wants to publish a Windows application to the
Microsoft Store, create or validate an MSIX, complete a Partner Center
submission, respond to certification findings, or monitor publishing status.

This skill is for Windows desktop applications, including packaged classic
Win32 apps that use `runFullTrust`. It is based on a successful end-to-end
submission using Partner Center, an unsigned Store MSIX, the Windows App
Certification Kit, and browser automation against Partner Center's current web
components.

## Required safeguards

Treat Partner Center as a production publishing system.

1. Never accept the Microsoft Application Developer Agreement or another
   binding agreement without fresh, action-specific user confirmation.
2. Never press **Submit for certification** without fresh, action-specific user
   confirmation after every section is complete and the final package is
   validated.
3. Never cancel certification, delete a submission, change pricing, change
   market availability, publish manually, or send Partner Center feedback
   without confirmation for that exact action.
4. Never enter, retrieve, print, save, or expose passwords, passkeys, tokens,
   recovery codes, MFA codes, or payment information. Let the user complete
   credential and operating-system authentication prompts.
5. Do not use the user's everyday browser profile for automation. Use a
   dedicated persistent profile and let the user authenticate interactively.
6. Do not infer legal, trademark, patent, privacy, export-control, or
   open-source compliance. Identify the concern and state when professional
   review is prudent.
7. Do not claim WACK passed from an exit code alone. Find and read the generated
   report and verify its overall result.
8. Do not upload a package until its identity, publisher, display name,
   architecture, version, and signing state have been inspected.

## Files in this skill

- `references/submission-playbook.md` - complete submission sequence.
- `references/msix-checklist.md` - package and manifest requirements.
- `references/partner-center-dom.md` - known routes, text, roles, custom
  elements, selectors, and automation quirks.
- `references/worked-example.md` - concrete lessons from a successful
  full-trust desktop app submission.
- `scripts/partner-center-control.mjs` - Playwright controller for a dedicated
  Edge profile.
- `scripts/inspect-msix.ps1` - unpack and inspect an MSIX before upload.
- `scripts/invoke-wack.ps1` - run WACK, then find, verify, and save its XML and
  HTML reports.
- `templates/run-full-trust-rationale.md` - restricted-capability explanation.
- `templates/certification-notes.md` - tester instructions.
- `templates/store-listing.md` - listing content worksheet.
- `templates/submission-record.json` - durable identity, package, validation,
  and certification handoff record.

Read the relevant reference before performing that phase. Do not rely on
remembered Partner Center labels because Microsoft changes the UI.

## End-to-end workflow

### 1. Establish scope and readiness

Collect or determine:

- Application name and reserved Store product name.
- Repository and build command.
- Application version and intended MSIX version.
- Supported architectures and minimum Windows version.
- Whether the app is WinUI, UWP, packaged Win32, Electron, Wails, Tauri, .NET,
  or another desktop technology.
- Native executables, helper processes, services, drivers, startup tasks,
  protocol handlers, file associations, codecs, and restricted capabilities.
- Privacy policy, support URL, website, licensing, notices, and corresponding
  source obligations.
- Real device and network requirements for certification.
- The Microsoft account the user wants selected during sign-in.

Inspect the repository before proposing package changes. Search for existing
manifest templates, package scripts, signing scripts, app icons, version
sources, privacy documents, third-party notices, and startup behavior.

### 2. Reserve the product and capture identity

In Partner Center, reserve an available product name. Avoid trademarked terms
in the product name unless the user has a documented right to use them.

After reservation, capture the exact values under:

**Product management > Product identity**

- Store product ID
- Package identity name
- Publisher ID, usually a `CN=...` value
- Publisher display name
- Package family name
- Reserved product display name
- Public Store URL

Never guess these values. The MSIX must use the Partner Center values exactly.

### 3. Build the Store package

Follow `references/msix-checklist.md`.

Important defaults:

- A Microsoft Store submission MSIX should be unsigned. Microsoft signs it
  after certification.
- A package distributed outside the Store must be signed with a certificate
  whose subject exactly matches the manifest publisher.
- The fourth version component must be `0` before Store submission because it
  is reserved for Microsoft.
- The first version component must be nonzero.
- `Package/Properties/DisplayName` must exactly match a reserved product name.
  Matching only the application's visual display name is not sufficient.

Prefer a repeatable build script that:

1. Validates input versions and identity values.
2. Builds or receives the production executable.
3. Stages the manifest, executable, assets, licenses, privacy information,
   notices, and source links.
4. Packs with `makeappx.exe`.
5. Unpacks the generated MSIX into a verification directory.
6. Verifies the unpacked executable hash.
7. Emits a JSON receipt with identity values and SHA-256 hashes.

### 4. Inspect and test the MSIX

Run:

```powershell
.\scripts\inspect-msix.ps1 `
  -PackagePath '<path-to-package.msix>' `
  -ExpectedIdentityName '<Partner Center identity name>' `
  -ExpectedPublisher '<Partner Center publisher ID>' `
  -ExpectedDisplayName '<reserved product name>'
```

The script belongs to this skill directory, not necessarily the application
repository. Resolve its full path before invoking it.

Install and exercise the packaged app under real package identity when
possible. Test:

- First launch and upgrade.
- Startup tasks and persisted settings.
- Helper process launch.
- Tray behavior and clean exit.
- Network discovery and firewall behavior.
- File and registry access under package identity.
- Uninstall and data retention behavior.
- Restricted capability behavior.

### 5. Run WACK

Run:

```powershell
# Run this command from an elevated PowerShell terminal.
.\scripts\invoke-wack.ps1 `
  -PackagePath '<path-to-package.msix>' `
  -ReportPath '<artifact-directory>\WACK-report.xml'
```

The WACK command-line runner requires elevation. The model must not approve the
operating-system authentication prompt for the user.

WACK accepts a report path only when it ends in `.xml`. Any other extension
fails with exit code -1 and `The report output file path should point to a XML
file.` Current kits write the XML report to the requested path and an HTML
rendering of it to `%LOCALAPPDATA%\Microsoft\AppCertKit\<same base name>.htm`.
One earlier run left only an HTML report in that folder and no XML report.

The script:

- Always passes WACK an `.xml` path. A `-ReportPath` ending in `.htm` or
  `.html` names the saved HTML report, and the XML report is saved beside it
  with the same base name.
- Uses the XML report from this run at the requested path, or else the newest
  WACK report in `%LOCALAPPDATA%\Microsoft\AppCertKit`. It skips files from
  earlier runs and the unrelated XML files WACK keeps in that folder, and keeps
  waiting while the report is still an unfinished interim report.
- Pairs the HTML report with the XML report by report time, then saves both
  beside `-ReportPath`.
- Reports the XML `OVERALL_RESULT` the way the HTML report shows it: `PASS` is
  `PASSED`, `FAIL` is `FAILED`, `WARNING` is `PASSED WITH WARNINGS`, and an
  interim report is `INCOMPLETE`. The HTML result is used only when there is
  no XML report.
- Fails when the result is not `PASSED`, not every test ran, the XML and HTML
  reports disagree, `appcert.exe` exited with an error, or the report's
  package identity name or version does not match the package. For a bundle,
  it warns instead, so check the name and version by hand.
- Lists every test that did not pass in `nonPassingTests`, with WACK's
  messages for it. An HTML-only result leaves this list empty.

When it finds a report, the script prints a JSON summary, then throws if
`passed` is `false`. Do not continue to submission unless `passed` is `true`.
`PASSED WITH WARNINGS` is not `PASSED`: fix the tests it names and rerun WACK.
Read every entry in `warnings`. Optional test failures do not block Store
onboarding, but investigate them.

Record the result in the submission record: `overallResult` as `wack.result`,
`xmlReport` as `wack.reportPath`, `kitVersion` as `wack.kitVersion`, and
`reportTime` as `wack.testedAt`. When `resultSource` is `html`, use
`htmlReport` as the report path and copy the kit version and report time from
the HTML report's header.

To parse and verify an existing report without running WACK again:

```powershell
.\scripts\invoke-wack.ps1 `
  -PackagePath '<path-to-package.msix>' `
  -ReportPath '<artifact-directory>\verified-WACK-report.xml' `
  -ExistingReportPath '<existing-WACK-report.xml>'
```

Pass an `.htm` report only when no XML report exists. Given an XML report, the
script looks for the HTML report with the same base name, beside it or in
`%LOCALAPPDATA%\Microsoft\AppCertKit`, and uses it when the report times
match. Given an HTML report, it uses the XML report beside it when the two
match.

### 6. Automate Partner Center carefully

Prefer app-native browser or computer-use tools when they can inspect and act
on the page reliably. Use `scripts/partner-center-control.mjs` as a fallback
when Partner Center web components or file uploads are unreliable.

Setup:

```powershell
Set-Location '<skill-directory>\scripts'
npm install
$env:PARTNER_CENTER_PROFILE = '<dedicated-profile-directory>'
node .\partner-center-control.mjs launch
```

Run `launch` as an attached background process. The user completes Microsoft
authentication in the dedicated Edge window. In a separate command process,
use read-only commands such as:

```powershell
node .\partner-center-control.mjs status
node .\partner-center-control.mjs pages
node .\partner-center-control.mjs text
node .\partner-center-control.mjs anchors
node .\partner-center-control.mjs overview-state
node .\partner-center-control.mjs agreement-state
node .\partner-center-control.mjs button-state "Submit for certification"
```

State-changing controller commands require:

```powershell
$env:PARTNER_CENTER_ALLOW_WRITE = '1'
```

Set it only after the user has authorized the exact action. Remove it after the
action so later commands fail closed.

Read `references/partner-center-dom.md` before using selectors.

### 7. Complete every submission section

Read `references/submission-playbook.md`. Typical sections are:

- Pricing and availability
- Properties
- Age ratings
- Packages
- Store listings
- Submission options
- Additional Testing Information

After saving each section, reload it and verify persistence. Partner Center can
temporarily omit completion states while its APIs are still loading. Wait and
reload before deciding a section is incomplete.

Do not make unsupported marketing or accessibility claims. Use genuine app
screenshots. Describe network, hardware, account, and protected-content
limitations plainly.

For `runFullTrust`, explain the exact native functionality, helper processes,
Windows APIs, startup integration, and why a sandboxed app model is
insufficient. State whether the app uses elevation, services, drivers,
system-wide settings, or a cloud backend.

### 8. Final readiness review

Before asking for submission approval, verify:

- Every Partner Center section says **Complete**.
- The package says **Validated**.
- `invoke-wack.ps1` reported `passed: true` for the final package: the WACK
  overall result is **PASSED**, and the report's identity name and version
  match the package.
- Package identity and display name match Partner Center.
- Package is unsigned for Store submission.
- Privacy, support, website, license, and notices are reachable.
- Certification notes are saved and include reproducible test instructions.
- Restricted capabilities have explanations.
- Publishing timing is what the user intends.
- No known blocker is being hidden.

### 9. Agreement acceptance

If Partner Center displays an updated agreement:

1. Open the agreements page and identify the exact agreement name and version.
2. Tell the user it is a binding legal agreement governing the developer
   account and Store submissions.
3. Ask for explicit approval to accept that exact version.
4. Reinspect the page after approval.
5. Accept only the intended row once.
6. Verify the status changed from `Not Accepted` to an acceptance date.

Do not reuse approval from another agreement or earlier action.

### 10. Submit for certification

When the button is enabled:

1. State that submission sends the product to Microsoft certification and
   whether it will publish automatically if approved.
2. Ask for explicit approval to press **Submit for certification**.
3. Reinspect the button after approval.
4. Click it once.
5. Do not submit the optional feedback survey without separate approval.
6. Verify the overview says **In certification** and shows the certification
   pipeline.

### 11. Monitor and respond

Certification commonly completes within a few hours but can take several
business days. Monitor:

- Submission
- Pre-processing
- Certification
- Publishing

If certification fails, quote the finding exactly, map it to the package,
listing, policy, or test behavior, make the smallest complete correction,
rebuild with a higher package version when required, rerun inspection and
WACK, and obtain fresh submission approval.

If the product is approved, verify the public Store URL from an unauthenticated
context. Do not claim it is live solely because Partner Center says
publishing completed.

## Output standard

Report concrete state:

- Product ID and reserved name.
- Package path, package version, architecture, signing state, and SHA-256.
- WACK result and report path.
- Completed Partner Center sections.
- Agreement name and acceptance state.
- Certification pipeline stage.
- Exact blockers and next action.

Never report a mutation as complete unless the resulting state was read back
from Partner Center or the generated artifact was inspected.

For long-running submissions, copy `templates/submission-record.json` into a
session artifact directory and update it after each meaningful state change.
Do not commit account identifiers, private URLs, credentials, or browser
profile data into the application repository.
references/
msix-checklist.md 3.3 KB
# MSIX checklist

## Identity

- Reserve the product name before the final Store build.
- Copy the package identity name and publisher ID from Partner Center.
- Use the exact publisher string, including the complete `CN=...` value.
- Set `Package/Properties/DisplayName` to an exact reserved product name.
- Keep the application visual display name and startup-task display name
  consistent with the reserved product name.
- Do not reuse a development identity for Store upload.

## Version

- Use four numeric components.
- The first component must be nonzero.
- Each component must fit the MSIX version range.
- The fourth component must be `0` before Store upload.
- Every replacement package must have a version greater than the version
  already associated with the product.
- Keep the application-visible version and package version mapping documented.

## Packaging

- Target only device families the app supports.
- Declare the correct processor architecture.
- Set a realistic minimum Windows version.
- Use `packagedClassicApp` and `mediumIL` for packaged classic Win32 apps when
  appropriate.
- Declare only capabilities the app actually needs.
- Include valid Store and application icons at every declared path.
- Include privacy information, license terms, third-party notices, and
  corresponding-source information when applicable.
- Package production binaries, not debug builds.
- Do not include private symbols, credentials, test data, caches, logs, or
  development configuration.

## Full-trust desktop apps

- Declare `rescap:Capability Name="runFullTrust"` only when necessary.
- Explain the exact Win32 functionality that requires full trust.
- Document helper executables and command-line modes.
- Avoid elevation unless essential.
- Avoid services, drivers, and system-wide changes when user-level behavior is
  sufficient.
- For startup behavior, prefer a packaged `windows.startupTask` and the Windows
  StartupTask API over registry startup entries.
- Test the startup task under real package identity.

## Signing

- Leave a Microsoft Store submission package unsigned.
- Microsoft signs the package after certification.
- Sign packages distributed outside the Store.
- For external signing, the certificate subject must exactly match the
  manifest publisher.
- Inspect the final package signing state before upload.

## Verification

- Unpack the final MSIX with `makeappx.exe`.
- Parse the unpacked `AppxManifest.xml`.
- Verify identity, publisher, version, architecture, display name, target
  device family, executable, trust level, capabilities, and extensions.
- Compare the unpacked executable SHA-256 to the package input.
- Record the MSIX SHA-256.
- Install and launch under package identity.
- Exercise startup, tray, helper process, networking, update, and uninstall
  behavior.
- Run WACK and read the generated report.

## Common rejection causes

- `Package/Properties/DisplayName` does not match a reserved name.
- Package uses a development identity.
- Version is not greater than an existing package version.
- Store package is signed with the wrong publisher certificate.
- Restricted capability is unexplained.
- Listing promises behavior the package does not provide.
- Certification instructions omit required hardware, network, or pairing
  steps.
- Privacy declaration conflicts with actual data handling.
- Screenshots are mockups rather than genuine app screens.
partner-center-dom.md 10.7 KB
# Partner Center DOM and automation reference

Partner Center is a single-page application built with custom web components.
Treat the details below as observed patterns, not permanent contracts. Inspect
the live DOM before every state-changing action.

## Routes

```text
Store developer home
https://storedeveloper.microsoft.com/

Product overview
https://partner.microsoft.com/en-US/dashboard/products/{productId}/overview

Agreements
https://partner.microsoft.com/en-US/dashboard/account/v3/settings/agreements

Submission sections
https://partner.microsoft.com/en-US/dashboard/products/{productId}/submissions/{submissionId}/availability
https://partner.microsoft.com/en-US/dashboard/products/{productId}/submissions/{submissionId}/properties
https://partner.microsoft.com/en-US/dashboard/products/{productId}/submissions/{submissionId}/ageratings
https://partner.microsoft.com/en-US/dashboard/products/{productId}/submissions/{submissionId}/packages
https://partner.microsoft.com/en-US/dashboard/products/{productId}/submissions/{submissionId}/managelanguages?producttype=app
https://partner.microsoft.com/en-US/dashboard/products/{productId}/submissions/{submissionId}/options

Additional testing information (product level, not under a submission)
https://partner.microsoft.com/en-US/dashboard/products/{productId}/suppinfo/additionaltestinginfo
```

Discover the current `submissionId` from overview-page anchors rather than
guessing it.

## Product overview

Observed card anchors use accessible labels similar to:

```text
Pricing and availability Status: - Complete
Properties Status: - Complete
Age ratings Status: - Complete
Packages Status: - Complete
Store listings Status: - Complete
Submission Options
```

Use `anchors` to capture their current `href` values.

The final control is normally discoverable as:

```javascript
page.getByRole("button", {
  name: "Submit for certification",
  exact: true
})
```

Always read `disabled` immediately before asking for approval and again after
approval.

Successful submission changes overview text to:

```text
In certification
Certification status
Submission
Pre-processing
Certification
Publishing
```

Each of the four steps is an `he-subway-stop` element. The current step has
the `current` attribute. This lists the steps and marks the current one:

```javascript
[...document.querySelectorAll("he-subway-stop")].map(
  (stop) =>
    stop.innerText.trim() + (stop.hasAttribute("current") ? " (current)" : ""),
);
```

After submission, the section-card labels can change from `Complete` to
`Read only`. The package filename and `Validated` text may no longer appear on
the overview. This is expected while the submitted revision is locked. Do not
interpret the absence of `Validated` as a package regression after the product
is already `In certification`.

An optional feedback dialog can appear immediately after submission. Do not
select a rating or press its **Submit** button without separate approval.

While the product is in certification, the overview also offers **Cancel
certification**. Never click it without approval for that exact action.

## Agreements

Current agreement rows have been observed inside:

```html
<accexp_he-data-grid>
```

The acceptance control has been observed as:

```html
<accexp_he-button
  appearance="link"
  testid="agreement-accept-now-button"
  type="button">
  Accept
</accexp_he-button>
```

Useful locator:

```javascript
page.getByText("Accept", { exact: true })
```

Multiple `Accept` controls can exist, including agreements for different
programs. Inspect the grid order and the row context. Do not click solely by
text when more than one match exists.

After acceptance, the row may update immediately without a confirmation
dialog. Verify:

- `Not Accepted` disappeared for the intended row.
- The agreement version remains visible.
- An acceptance date is shown.

## Age ratings

Once a questionnaire draft exists, the `ageratings` route redirects to
`ageratings/summary`. That page shows the rating preview and an `he-checkbox`
that agrees to the IARC Terms of Use and states that the user is of the age of
majority. Ticking it accepts a binding agreement, so get approval first.

Observed behavior of that checkbox:

- Its real input is in shadow DOM with an empty label. `getByLabel` times out,
  and clicking the inner input did not tick it.
- Its label contains a link to the IARC terms, so clicking the label text can
  open the link instead of ticking the box.
- Clicking the `he-checkbox` host element 8 pixels in from its left edge and
  no more than 12 pixels down from its top, where the box is drawn, ticked it.

Save is an `he-button` that stays disabled until the box is ticked. After a
successful save, the heading changes from `Age ratings preview` to
`Age ratings`, and `Current Rating ID` shows `Pending` until certification.

## Pricing

Until a base price is saved, the Pricing and availability page warns:

```text
No PriceSchedule created for purchasable product. You must configure a price for this product.
```

The base price uses two `he-select` elements:

- `he-select[label="Currency"]` lists markets with their currency, such as
  `USD - United States` (value `US`). Its roughly 240 options load only after
  the list opens.
- `he-select[label="Retail price"]` stays disabled until a currency is chosen.
  It then lists price tiers: `0`, `0.99`, and so on. There is no `Free`
  option. Tier `0` makes the product free.

Each `he-select` keeps an `input[role="combobox"]` in shadow DOM, so the
controller's `comboboxes` command lists them. Their indexes depend on the
page, so list them again right before using an index. To pick an option,
search only inside the intended `he-select`, for example:

```javascript
page
  .locator('he-select[label="Retail price"]')
  .getByRole("option", { name: "0", exact: true });
```

Save is `input[type="submit"][value="Save draft"]`. A successful save
redirects to the product overview. Reopen the page to verify the price and
markets. Changing pricing needs approval for that exact change.

## Saving sections

- Properties, Packages, and Submission options: `click-role button Save`
  saves and redirects to the product overview.
- Pricing and availability: **Save draft**, as described above.
- Additional testing information: the certification notes field is
  `textarea#certificationNotesDescription` (6000-character limit). Its save
  control is a link, `<a aria-label="Save description">`, so
  `click-role button Save` does not find it. Use
  `click-text-ancestor "Save description"`.

After every save, reopen the section and verify that the values persisted.

## Inputs and web components

Partner Center uses standard inputs mixed with custom elements such as:

```text
he-button
he-checkbox
he-select
he-subway-stop
accexp_he-button
accexp_he-data-grid
```

Playwright role, label, and exact-text locators generally work across these
components. When they do not, inspect:

- `tagName`
- `role`
- `aria-label`
- `aria-labelledby`
- `href`
- `disabled`
- `testid`
- ancestor text
- `outerHTML`

Avoid brittle generated CSS class names.

Observed input quirks:

- `he-checkbox` keeps its real input in shadow DOM with an empty label, so
  `getByLabel` and the controller's `check-label` and `uncheck-label` time
  out. `click-text` with the exact label text toggles most of them. The
  age-ratings terms box is an exception, described under Age ratings.
- A `span` inside radio labels intercepts pointer events, so Playwright's
  `check()` can fail. Clicking the label works.
- Exact-text matching must include curly apostrophes (U+2019) where Partner
  Center uses them.

## File uploads

Package and screenshot uploads use hidden:

```css
input[type="file"]
```

Observed screenshot behavior:

1. Uploading a screenshot creates another hidden file input.
2. Reusing input index `0` may replace the first screenshot.
3. Upload each additional screenshot through the next file-input index.
4. After every upload, wait for processing and count inputs again.
5. Verify every intended screenshot is visible before saving.

Use genuine app screenshots. Resize them outside Partner Center when required.

## Loading and stale state

Partner Center can temporarily show:

- Section labels without `Complete`.
- Package cards without the package filename.
- A disabled submission button after agreement acceptance.
- Only navigation chrome after reload.

Do not edit immediately. Wait, reload, and reread the page. A successful
pattern is:

1. Navigate directly to the product overview.
2. Wait 8 to 15 seconds.
3. Read body text.
4. Reload once if section state is missing.
5. Wait again and verify.

## Authentication redirects

Partner Center can redirect an authenticated product page to Microsoft's
account picker when a session expires. Before reading product or agreement
state, verify the current URL still belongs to `partner.microsoft.com`.

If the user previously specified the exact account to use, select only that
account. Never enter or retrieve credentials. Stop for user action if Microsoft
requests a password, passkey, MFA, CAPTCHA, or operating-system
authentication. After sign-in, navigate directly back to the intended product
or agreement URL and reread the page.

## Multiple tabs

Multiple Partner Center tabs can cause automation to target the wrong page.
The supplied controller:

- Lists all pages with `pages`.
- Prefers the most recently opened Partner Center page.
- Accepts `PARTNER_CENTER_PAGE_INDEX` to select an exact page.

Check `status` before every state-changing command.

## Safe inspection commands

```powershell
node .\partner-center-control.mjs status
node .\partner-center-control.mjs pages
node .\partner-center-control.mjs text
node .\partner-center-control.mjs anchors
node .\partner-center-control.mjs inputs
node .\partner-center-control.mjs radios
node .\partner-center-control.mjs comboboxes
node .\partner-center-control.mjs find-text "Accept"
node .\partner-center-control.mjs match-context "Accept"
node .\partner-center-control.mjs button-state "Submit for certification"
```

`combo-options <index>` is listed as read-only in the controller's help, but it
clicks the combobox to open its list. It does not choose an option or save,
but it does change the page. Run `comboboxes` first to confirm the index, and
run `reload` afterward if the open list gets in the way.

## State-changing commands

The controller rejects these unless
`PARTNER_CENTER_ALLOW_WRITE=1`:

- `click-role`
- `click-text`
- `click-text-index`
- `click-text-ancestor`
- `fill-label`
- `fill-selector`
- `check-label`
- `uncheck-label`
- `check-name` (types a product name and clicks **Check availability**)
- `select-combo`
- `add-combo-token`
- `upload-file`
- `upload-file-index`

The environment variable is a technical guard, not user approval. Obtain
approval first when required, set it for the single action, perform the action,
then clear it.
submission-playbook.md 5.0 KB
# Microsoft Store submission playbook

## Before Partner Center

1. Finish product behavior and package-specific integration.
2. Test the application on supported Windows versions and architectures.
3. Prepare:
   - Privacy policy
   - Support URL
   - Product website
   - License
   - Third-party notices
   - Corresponding-source information where required
   - Genuine screenshots
   - App icons
   - Certification instructions
4. Identify features that affect policy or review:
   - Full trust
   - Startup tasks
   - Background behavior
   - Local network access
   - Accounts
   - Personal information
   - Purchases
   - User-generated or externally displayed content
   - Mature content
   - Location
   - Generative AI

## Product creation

1. Check candidate names.
2. Reserve the final name.
3. Capture product identity values.
4. Rebuild the MSIX with those exact values.
5. Inspect the package.
6. Run WACK.

If a name uses another company's product or protocol trademark, stop and
review the trademark owner's naming guidance. Prefer compatibility language in
the description over using the trademark in the product name.

## Pricing and availability

Decide and verify:

- Free or paid.
- Markets.
- Public discoverability.
- Release timing.
- Automatic or manual publishing after certification.
- Organizational licensing if relevant.

Saving pricing or market changes affects commercial availability. Obtain user
approval for the exact settings before saving if the user has not already
specified them.

## Properties

Choose categories based on actual function. Supply:

- Privacy policy URL
- Website
- Support URL
- Personal-information declaration
- Hardware and feature declarations

Do not select accessibility certification, removable storage, OneDrive backup,
game recording, mixed reality, pen, purchases, or generative AI unless the app
really supports and has tested that claim.

## Age ratings

Answer based on the app's behavior, not the intended audience.

An app that displays live content from another paired device may need to say it
accesses online content even if it does not curate a content catalog. That does
not automatically mean the seller provides mature content, communication,
purchases, rewards, precise location sharing, or unrestricted web browsing.

Accepting IARC terms is a legal action. Obtain fresh user approval immediately
before acceptance.

## Packages

1. Upload the final unsigned Store MSIX.
2. Wait for validation.
3. Verify:
   - Filename
   - Package version
   - Architecture
   - Device family
   - Minimum OS
   - Validation state
4. Resolve every blocking error.
5. Explain every restricted-capability warning.

If Partner Center rejects the package display name, inspect
`Package/Properties/DisplayName`. It must match a reserved name exactly.

## Submission options

Set publishing timing and explain restricted capabilities.

A useful full-trust explanation names:

- The app's desktop technology.
- Native Windows APIs used.
- Helper process architecture.
- Startup and tray integration.
- Whether elevation, services, drivers, system-wide settings, and cloud
  services are used.

Use `templates/run-full-trust-rationale.md`.

## Store listing

Provide:

- Product description with concrete behavior.
- Connection or setup steps.
- Feature list.
- Short title.
- Developer name.
- Search keywords.
- Privacy behavior.
- Hardware and network requirements.
- Known limitations, including DRM or protected content.
- Trademark attribution and non-affiliation statements where appropriate.
- License and source information.
- Genuine screenshots with readable dimensions.

Do not invent awards, metrics, customer quotes, compatibility, accessibility,
or security claims.

## Additional Testing Information

Write instructions that a Microsoft tester can execute without guessing:

- Required Windows version and architecture.
- Required physical hardware.
- Same-network or firewall requirements.
- Exact connection flow.
- Pairing or PIN steps.
- Expected windows, tray icons, notifications, audio, and full-screen behavior.
- Startup behavior.
- Helper process behavior.
- Protected-content limitations.
- Whether credentials are required.
- How to find logs if testing fails.

Use `templates/certification-notes.md`.

## Final submission

1. Reload overview.
2. Verify every section is complete.
3. Verify the package is validated.
4. Verify WACK passed.
5. Handle updated agreements with separate approval.
6. Reload overview again.
7. Verify **Submit for certification** is enabled.
8. Tell the user automatic publishing consequences.
9. Obtain fresh approval.
10. Submit once.
11. Verify **In certification**.

## After submission

Monitor Partner Center and the public Store page. Certification may include:

- Pre-processing
- Package security checks
- Technical compliance
- Content compliance
- Restricted-capability review
- Publishing

When approved, verify the public URL anonymously. When rejected, preserve the
finding text and any report attachments before editing the next submission.
worked-example.md 8.4 KB
# Worked example: full-trust desktop receiver

This example records decisions and failures from a successful Microsoft Store
submission. It is prior art, not a template to copy without checking the new
application.

## Product and package

- Product: Mirror Me Phone Display
- Store product ID, package identity name, publisher ID, publisher display
  name, and package family name: copied exactly from Partner Center
  **Product identity**. They are specific to one developer account, so they
  are not repeated here. The publisher ID has the form `CN=<GUID>`, and the
  package family name is the identity name, an underscore, and a
  13-character publisher hash.
- Application version: `0.2.4`
- MSIX version: `1.2.4.0`
- Architecture: x64
- Minimum Windows version: `10.0.22000.0`

The application version was below 1.0, but MSIX does not allow a zero first
component. The build mapped `0.2.4` to `1.2.4.0`. The fourth component remained
zero for Store use.

## Package model

The application was a Wails and Win32 desktop app with:

- `packagedClassicApp`
- `mediumIL`
- `runFullTrust`
- A packaged `windows.startupTask`
- A same-executable `--receiver-worker` mode
- Native DNS-SD, Media Foundation, GDI, and WASAPI integration
- No elevation
- No service
- No driver
- No system-wide settings
- No cloud backend

The Store MSIX was intentionally unsigned. Microsoft signs Store packages
after certification.

## First package rejection

The initial upload used the correct application visual name but retained:

```xml
<Properties>
  <DisplayName>MirrorMe</DisplayName>
</Properties>
```

`MirrorMe` was not a reserved Store name. Partner Center rejected the package.

The fix was to parameterize every display-name surface and rebuild with:

```xml
<Properties>
  <DisplayName>Mirror Me Phone Display</DisplayName>
</Properties>
```

The application visual display name and StartupTask display name were changed
to the same reserved name.

Lesson: validate `Package/Properties/DisplayName`, not only
`uap:VisualElements/@DisplayName`.

## Name and trademark decision

Names containing `AirPlay` were considered, but Apple naming guidance made a
protocol trademark in the product name undesirable without permission.

The reserved name avoided the trademark. Compatibility language appeared in
the listing instead:

> Compatible with screen mirroring from AirPlay-enabled devices.

The listing also stated:

> AirPlay is a trademark of Apple Inc. This app is not affiliated with or
> endorsed by Apple.

Lesson: use protocol compatibility language in the description when the
trademark should not appear in the product name.

## Partner Center configuration

Pricing and availability:

- Free
- All current and future markets
- Public and discoverable
- Publish automatically after certification

Properties:

- Primary category: Utilities + tools
- Secondary category: Photo + video
- Personal information use: Yes
- Privacy, website, and support URLs supplied
- Unsupported capability and accessibility claims left disabled

Age rating:

- App type: All Other App Types
- Online content: Yes, because the app displays live content from a paired
  phone
- Seller-curated mature content: No
- User communication: No
- Purchases and rewards: No
- Precise location sharing: No
- Browser behavior: No
- Result: all-ages ratings, including ESRB Everyone and Microsoft 3+

Lesson: displaying external live content can count as online content without
making the app a browser, communications service, or mature-content catalog.

## Full-trust explanation

The explanation covered:

- The Wails and native Win32 desktop architecture
- The same-executable receiver worker
- DNS-SD discovery
- Media Foundation, GDI, and WASAPI
- Packaged startup-task behavior
- Tray and lifecycle integration
- No elevation, driver, service, system-wide setting, or cloud backend

Lesson: name the concrete native behavior and explicitly rule out higher-risk
system integration that the app does not use.

## Store listing

The listing included:

- Concrete connection steps
- Same-network requirement
- Pairing PIN behavior
- Local privacy behavior
- Firewall and multicast requirements
- Protected and DRM content limitations
- Apple trademark attribution
- GPL v3-or-later and corresponding-source information
- Genuine light and dark application screenshots

Two screenshots were resized to a Store-recommended width before upload.

## Screenshot upload quirk

Partner Center created a new hidden file input after every screenshot upload.
Uploading both screenshots through input index `0` targeted or replaced the
first screenshot. The second screenshot had to use the next input index.

Lesson: recount `input[type="file"]` after every upload and use the newly
created input for the next image.

## Certification instructions

The testing notes explained:

- Same-network setup
- iPhone Control Center and Screen Mirroring connection flow
- PIN pairing
- Full-screen and tray behavior
- Packaged StartupTask behavior
- Same-executable worker architecture
- Firewall and multicast requirements
- Protected-content limitations
- Trademark and non-affiliation language

No credentials were required.

## WACK

The WACK command returned exit code zero, but the requested XML report was not
found. The real report was generated as:

```text
%LOCALAPPDATA%\Microsoft\AppCertKit\MirrorMe-WACK-report.htm
```

The HTML report said:

```text
Overall result: PASSED
```

Required and optional tests passed.

Lesson: locate and parse the generated report. Do not trust the process exit
code by itself.

A later submission of another full-trust desktop app used WACK
10.0.26100.8249:

- A report path ending in `.htm` failed with exit code -1 and
  `The report output file path should point to a XML file.`
- A path ending in `.xml` worked. WACK wrote the XML report to that path and an
  HTML rendering to `%LOCALAPPDATA%\Microsoft\AppCertKit\<same base name>.htm`,
  both before it exited.
- The XML root element, `REPORT`, carries `OVERALL_RESULT`, `VERSION` (the kit
  version), `APP_NAME` (the package identity name, not the display name),
  `APP_VERSION`, `PARTIAL_RUN`, and `ReportGenerationTime`. The HTML report
  shows the same report time, which ties the two files together.
- `OVERALL_RESULT` is `PASS`, `FAIL`, or `WARNING`. The HTML report shows these
  as `PASSED`, `FAILED`, and `PASSED WITH WARNINGS`. The original
  `invoke-wack.ps1` read only the first word after `Overall result:`, so it
  would have reported `PASSED WITH WARNINGS` as `PASSED`.
- The optional `Blocked executables` test failed while the overall result was
  still `PASS`. WACK flagged a reference to the process-launch API
  `kernel32.dll!CreateProcessW` and references to blocked executables such as
  `Cmd`, `powershell`, and `REG` in the main executable, and `bash` and `reg`
  in a bundled helper executable. The report says optional tests "are
  informational only and will not be used to evaluate your app during
  Microsoft Store onboarding."
- `%LOCALAPPDATA%\Microsoft\AppCertKit` also held about 30 unrelated XML files,
  such as logs and a task schedule. Only a file whose root element is `REPORT`
  is a WACK report.

Lesson: treat the XML report as the source of truth, match its identity name
and version to the package, and fall back to the HTML report only when no XML
report exists.

## Agreement and final submission

Partner Center required:

- App Developer Agreement - Windows Store
- Version 8.11

The exact agreement was presented to the user as a binding legal agreement.
After explicit approval, the Windows agreement row was accepted and verified
by its acceptance date.

After a reload, every section returned to `Complete`, the package returned to
`Validated`, and **Submit for certification** became enabled.

The user was told that approval would publish the product automatically. After
fresh explicit approval, the button was pressed once. Partner Center changed
to:

```text
In certification
Submission
Pre-processing
Certification
Publishing
```

An optional feedback survey appeared after submission and was left untouched.

## Remaining risks at submission time

- Broad physical-device and network testing was incomplete.
- A rare quit hang had not been conclusively eliminated.
- Legal review of protocol interoperability, trademarks, codec patents, and
  GPL distribution remained prudent.
- `runFullTrust` still required Microsoft's certification approval.

Lesson: passing package validation and WACK does not erase product,
interoperability, legal, or restricted-capability risk.
scripts/
inspect-msix.ps1 6.4 KB
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [string]$PackagePath,
    [string]$ExpectedIdentityName = "",
    [string]$ExpectedPublisher = "",
    [string]$ExpectedDisplayName = "",
    [string]$ExpectedArchitecture = "",
    [switch]$RequireUnsigned
)

$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest

function Resolve-WindowsSdkTool([string]$Name) {
    $sdkRoot = Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\bin"
    $candidates = @(
        Get-ChildItem -LiteralPath $sdkRoot -Directory -ErrorAction SilentlyContinue |
            Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } |
            Sort-Object { [version]$_.Name } -Descending |
            ForEach-Object { Join-Path $_.FullName "x64\$Name" } |
            Where-Object { Test-Path -LiteralPath $_ }
    )
    if (-not $candidates.Count) {
        throw "$Name was not found. Install the Windows 11 SDK."
    }
    return $candidates[0]
}

$resolvedPackage = (Resolve-Path -LiteralPath $PackagePath).Path
$makeAppx = Resolve-WindowsSdkTool "makeappx.exe"
$verificationRoot = Join-Path $env:TEMP (
    "microsoft-store-publisher\" + [Guid]::NewGuid().ToString("N")
)

New-Item -ItemType Directory -Path $verificationRoot -Force | Out-Null
try {
    & $makeAppx unpack /p $resolvedPackage /d $verificationRoot /o | Out-Host
    if ($LASTEXITCODE -ne 0) {
        throw "makeappx.exe failed with exit code $LASTEXITCODE."
    }

    $manifestPath = Join-Path $verificationRoot "AppxManifest.xml"
    if (-not (Test-Path -LiteralPath $manifestPath)) {
        throw "The unpacked package does not contain AppxManifest.xml."
    }

    [xml]$manifest = Get-Content -LiteralPath $manifestPath -Raw
    $namespaces = [Xml.XmlNamespaceManager]::new($manifest.NameTable)
    $namespaces.AddNamespace(
        "f",
        "http://schemas.microsoft.com/appx/manifest/foundation/windows10"
    )
    $namespaces.AddNamespace(
        "uap",
        "http://schemas.microsoft.com/appx/manifest/uap/windows10"
    )
    $namespaces.AddNamespace(
        "uap10",
        "http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
    )
    $namespaces.AddNamespace(
        "rescap",
        "http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
    )

    $identity = $manifest.SelectSingleNode("/f:Package/f:Identity", $namespaces)
    $properties = $manifest.SelectSingleNode("/f:Package/f:Properties", $namespaces)
    $application = $manifest.SelectSingleNode(
        "/f:Package/f:Applications/f:Application",
        $namespaces
    )
    $visualElements = $manifest.SelectSingleNode(
        "/f:Package/f:Applications/f:Application/uap:VisualElements",
        $namespaces
    )
    $targetFamilies = @(
        $manifest.SelectNodes(
            "/f:Package/f:Dependencies/f:TargetDeviceFamily",
            $namespaces
        )
    )
    $capabilities = @(
        $manifest.SelectNodes("/f:Package/f:Capabilities/*", $namespaces) |
            ForEach-Object { $_.Name }
    )

    if (-not $identity -or -not $properties -or -not $application) {
        throw "The manifest is missing identity, properties, or application data."
    }

    $displayName = [string]$properties.DisplayName
    $visualDisplayName = if ($visualElements) {
        [string]$visualElements.DisplayName
    } else {
        ""
    }
    $signature = Get-AuthenticodeSignature -LiteralPath $resolvedPackage
    $errors = [Collections.Generic.List[string]]::new()

    if ($ExpectedIdentityName -and $identity.Name -ne $ExpectedIdentityName) {
        $errors.Add(
            "Identity name '$($identity.Name)' does not match '$ExpectedIdentityName'."
        )
    }
    if ($ExpectedPublisher -and $identity.Publisher -ne $ExpectedPublisher) {
        $errors.Add(
            "Publisher '$($identity.Publisher)' does not match '$ExpectedPublisher'."
        )
    }
    if ($ExpectedDisplayName -and $displayName -ne $ExpectedDisplayName) {
        $errors.Add(
            "Package display name '$displayName' does not match '$ExpectedDisplayName'."
        )
    }
    if (
        $ExpectedDisplayName -and
        $visualDisplayName -and
        $visualDisplayName -ne $ExpectedDisplayName
    ) {
        $errors.Add(
            "Visual display name '$visualDisplayName' does not match '$ExpectedDisplayName'."
        )
    }
    if (
        $ExpectedArchitecture -and
        $identity.ProcessorArchitecture -ne $ExpectedArchitecture
    ) {
        $errors.Add(
            "Architecture '$($identity.ProcessorArchitecture)' does not match '$ExpectedArchitecture'."
        )
    }
    if ($RequireUnsigned -and $signature.Status -ne "NotSigned") {
        $errors.Add(
            "Store submission package must be unsigned, but signature status is '$($signature.Status)'."
        )
    }

    $version = [version]$identity.Version
    if ($version.Major -eq 0) {
        $errors.Add("The first MSIX version component must be nonzero.")
    }
    if ($version.Revision -ne 0) {
        $errors.Add("The fourth MSIX version component must be 0 for Store submission.")
    }

    $result = [ordered]@{
        package = $resolvedPackage
        packageSha256 = (
            Get-FileHash -LiteralPath $resolvedPackage -Algorithm SHA256
        ).Hash.ToLowerInvariant()
        identityName = [string]$identity.Name
        publisher = [string]$identity.Publisher
        version = [string]$identity.Version
        architecture = [string]$identity.ProcessorArchitecture
        displayName = $displayName
        visualDisplayName = $visualDisplayName
        applicationId = [string]$application.Id
        executable = [string]$application.Executable
        runtimeBehavior = [string]$application.RuntimeBehavior
        trustLevel = [string]$application.TrustLevel
        targetDeviceFamilies = @(
            $targetFamilies | ForEach-Object {
                [ordered]@{
                    name = [string]$_.Name
                    minVersion = [string]$_.MinVersion
                    maxVersionTested = [string]$_.MaxVersionTested
                }
            }
        )
        capabilities = $capabilities
        signatureStatus = [string]$signature.Status
        errors = @($errors)
        passed = $errors.Count -eq 0
    }

    $result | ConvertTo-Json -Depth 8
    if ($errors.Count) {
        throw "MSIX inspection failed with $($errors.Count) error(s)."
    }
} finally {
    if (Test-Path -LiteralPath $verificationRoot) {
        Remove-Item -LiteralPath $verificationRoot -Recurse -Force
    }
}
invoke-wack.ps1 19.4 KB
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [string]$PackagePath,
    [string]$ReportPath = "",
    [string]$ExistingReportPath = "",
    [int]$ReportWaitSeconds = 60
)

$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest

# WACK rejects a -reportoutputpath that does not end in .xml. It writes the XML
# report to the requested path and an HTML rendering of it to
# %LOCALAPPDATA%\Microsoft\AppCertKit\<xml base name>.htm. The XML report is
# authoritative. The HTML report is only a fallback when no XML report exists.
$kitDirectory = Join-Path $env:LOCALAPPDATA "Microsoft\AppCertKit"
$htmlExtensions = @(".htm", ".html")
# The English strings results.xsl shows for each OVERALL_RESULT value.
$resultNames = @{
    PASS = "PASSED"
    FAIL = "FAILED"
    WARNING = "PASSED WITH WARNINGS"
}
$knownResults = @($resultNames.Values) + "INCOMPLETE"

function Resolve-AppCert {
    $candidates = @(
        @(
            (Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\App Certification Kit\appcert.exe"),
            (Join-Path $env:ProgramFiles "Windows Kits\10\App Certification Kit\appcert.exe")
        ) | Where-Object { $_ -and (Test-Path -LiteralPath $_) }
    )
    if (-not $candidates.Count) {
        throw "appcert.exe was not found. Install the Windows App Certification Kit."
    }
    return $candidates[0]
}

function Test-IsAdministrator {
    $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = [Security.Principal.WindowsPrincipal]::new($identity)
    return $principal.IsInRole(
        [Security.Principal.WindowsBuiltInRole]::Administrator
    )
}

function Read-XmlDocument($Source) {
    $settings = [Xml.XmlReaderSettings]::new()
    $settings.DtdProcessing = [Xml.DtdProcessing]::Ignore
    $settings.XmlResolver = $null
    $reader = [Xml.XmlReader]::Create($Source, $settings)
    try {
        $document = [Xml.XmlDocument]::new()
        $document.Load($reader)
        return $document
    } finally {
        $reader.Dispose()
    }
}

function Get-PackageIdentity([string]$Path) {
    if ([IO.Path]::GetExtension($Path) -notin @(".msix", ".appx")) {
        return $null
    }
    Add-Type -AssemblyName System.IO.Compression.FileSystem
    $archive = [IO.Compression.ZipFile]::OpenRead($Path)
    try {
        $entry = $archive.GetEntry("AppxManifest.xml")
        if ($null -eq $entry) {
            throw "The package does not contain AppxManifest.xml."
        }
        $stream = $entry.Open()
        try {
            $manifest = Read-XmlDocument $stream
        } finally {
            $stream.Dispose()
        }
    } finally {
        $archive.Dispose()
    }
    $identity = $manifest.DocumentElement.SelectSingleNode(
        "*[local-name()='Identity']"
    )
    if ($null -eq $identity) {
        throw "AppxManifest.xml does not contain an Identity element."
    }
    return [pscustomobject]@{
        Name = $identity.GetAttribute("Name")
        Version = $identity.GetAttribute("Version")
    }
}

function Read-WackXmlReport([string]$Path) {
    try {
        $stream = [IO.File]::OpenRead($Path)
        try {
            $document = Read-XmlDocument $stream
        } finally {
            $stream.Dispose()
        }
    } catch {
        return $null
    }
    $root = $document.DocumentElement
    if (
        $null -eq $root -or
        $root.LocalName -ne "REPORT" -or
        -not $root.HasAttribute("OVERALL_RESULT")
    ) {
        return $null
    }

    $rawResult = $root.GetAttribute("OVERALL_RESULT")
    # results.xsl treats a report without VALIDATION_TYPE as an unfinished
    # interim report.
    $overallResult = if (-not $root.HasAttribute("VALIDATION_TYPE")) {
        "INCOMPLETE"
    } elseif ($resultNames.ContainsKey($rawResult)) {
        $resultNames[$rawResult]
    } else {
        $rawResult
    }

    $nonPassingTests = @(
        foreach ($test in $root.SelectNodes("REQUIREMENTS/REQUIREMENT/TEST")) {
            $resultNode = $test.SelectSingleNode("RESULT")
            $testResult = if ($null -ne $resultNode) {
                $resultNode.InnerText.Trim()
            } else {
                ""
            }
            if ($testResult -eq "PASS") {
                continue
            }
            [ordered]@{
                requirement = $test.ParentNode.GetAttribute("TITLE")
                test = $test.GetAttribute("NAME")
                result = $testResult
                optional = $test.GetAttribute("OPTIONAL") -eq "TRUE"
                messages = @(
                    foreach ($message in $test.SelectNodes("MESSAGES/MESSAGE")) {
                        $message.GetAttribute("TEXT")
                    }
                )
            }
        }
    )

    return [pscustomobject]@{
        Path = $Path
        OverallResult = $overallResult
        AppName = $root.GetAttribute("APP_NAME")
        AppVersion = $root.GetAttribute("APP_VERSION")
        KitVersion = $root.GetAttribute("VERSION")
        KitIsLatest = $root.GetAttribute("LATEST_VERSION") -ne "FALSE"
        PartialRun = $root.GetAttribute("PARTIAL_RUN") -eq "TRUE"
        ReportTime = $root.GetAttribute("ReportGenerationTime")
        NonPassingTests = $nonPassingTests
    }
}

function Read-WackHtmlReport([string]$Path) {
    try {
        $content = [IO.File]::ReadAllText($Path)
    } catch {
        return $null
    }
    $overall = [regex]::Match(
        $content,
        '(?is)<div[^>]*\bclass="overall"[^>]*>(.*?)</div>'
    )
    if (-not $overall.Success) {
        return $null
    }
    $label = [regex]::Match(
        $overall.Groups[1].Value,
        '(?is)<span[^>]*>(.*?)</span>'
    )
    if (-not $label.Success) {
        return $null
    }
    $text = [regex]::Replace($label.Groups[1].Value, '<[^>]+>', ' ')
    $text = ([Net.WebUtility]::HtmlDecode($text) -replace '\s+', ' ').Trim()
    $overallResult = if ($text -like "Incomplete*") {
        "INCOMPLETE"
    } else {
        $text.ToUpperInvariant()
    }
    $version = [regex]::Match(
        $content,
        '(?is)<dt>\s*App version:\s*</dt>\s*<dd[^>]*>\s*([^<]*?)\s*</dd>'
    )
    return [pscustomobject]@{
        Path = $Path
        Content = $content
        OverallResult = $overallResult
        # Only English reports are recognized. Localized labels are not.
        Recognized = $overallResult -in $knownResults
        AppVersion = if ($version.Success) { $version.Groups[1].Value } else { "" }
    }
}

# WACK copies ReportGenerationTime into the HTML report, which ties an HTML
# report to the XML report it was rendered from.
function Test-ReportPair($XmlReport, $HtmlReport) {
    return [bool](
        $XmlReport.ReportTime -and
        $HtmlReport.Content.Contains($XmlReport.ReportTime)
    )
}

function Test-NewFile([string]$Path, [datetime]$Since) {
    return (
        (Test-Path -LiteralPath $Path -PathType Leaf) -and
        (Get-Item -LiteralPath $Path).LastWriteTime -ge $Since
    )
}

function Get-NewKitFiles([string[]]$Extensions, [datetime]$Since) {
    if (-not (Test-Path -LiteralPath $kitDirectory -PathType Container)) {
        return
    }
    Get-ChildItem -LiteralPath $kitDirectory -File -ErrorAction SilentlyContinue |
        Where-Object {
            $_.Extension -in $Extensions -and $_.LastWriteTime -ge $Since
        } |
        Sort-Object LastWriteTime -Descending |
        ForEach-Object { $_.FullName }
}

function Find-WackXmlReport([string]$RequestedPath, [datetime]$Since) {
    $candidates = [Collections.Generic.List[string]]::new()
    if (Test-NewFile $RequestedPath $Since) {
        $candidates.Add($RequestedPath)
    }
    # AppCertKit holds many unrelated XML files, so each one is checked for a
    # REPORT root element.
    foreach ($path in @(Get-NewKitFiles @(".xml") $Since)) {
        $candidates.Add($path)
    }
    foreach ($candidate in $candidates) {
        $report = Read-WackXmlReport $candidate
        if ($null -ne $report) {
            return $report
        }
    }
    return $null
}

function Find-WackHtmlReport(
    [string]$XmlPath,
    [datetime]$Since,
    $XmlReport,
    [switch]$SearchKitDirectory
) {
    $baseName = [IO.Path]::GetFileNameWithoutExtension($XmlPath)
    $candidates = [Collections.Generic.List[string]]::new()
    foreach ($extension in $htmlExtensions) {
        $paths = @(
            [IO.Path]::ChangeExtension($XmlPath, $extension),
            (Join-Path $kitDirectory ($baseName + $extension))
        )
        foreach ($path in $paths) {
            if (Test-NewFile $path $Since) {
                $candidates.Add($path)
            }
        }
    }
    if ($SearchKitDirectory) {
        foreach ($path in @(Get-NewKitFiles $htmlExtensions $Since)) {
            $candidates.Add($path)
        }
    }
    foreach ($candidate in $candidates) {
        $report = Read-WackHtmlReport $candidate
        if (
            $null -ne $report -and
            ($null -eq $XmlReport -or (Test-ReportPair $XmlReport $report))
        ) {
            return $report
        }
    }
    return $null
}

function Save-Report([string]$Source, [string]$Destination) {
    # Skip the copy when the destination already holds the same report. This
    # also covers a source and destination that are the same file.
    if (
        -not (Test-Path -LiteralPath $Destination -PathType Leaf) -or
        (Get-FileHash -LiteralPath $Source).Hash -ne
            (Get-FileHash -LiteralPath $Destination).Hash
    ) {
        [IO.File]::Copy($Source, $Destination, $true)
    }
    return $Destination
}

$resolvedPackage = (Resolve-Path -LiteralPath $PackagePath).ProviderPath
if (-not $ReportPath) {
    $ReportPath = [IO.Path]::Combine(
        [IO.Path]::GetDirectoryName($resolvedPackage),
        [IO.Path]::GetFileNameWithoutExtension($resolvedPackage) +
            "-WACK-report.xml"
    )
}
# Relative paths are resolved against the current PowerShell location.
$ReportPath = [IO.Path]::GetFullPath(
    $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath(
        $ReportPath
    )
)
$reportExtension = [IO.Path]::GetExtension($ReportPath).ToLowerInvariant()
if ($reportExtension -ne ".xml" -and $reportExtension -notin $htmlExtensions) {
    throw "ReportPath must end in .xml, .htm, or .html."
}
# WACK always receives the .xml path. The HTML copy is saved beside it.
$xmlPath = [IO.Path]::ChangeExtension($ReportPath, ".xml")
$htmlPath = if ($reportExtension -eq ".xml") {
    [IO.Path]::ChangeExtension($ReportPath, ".htm")
} else {
    $ReportPath
}
[void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($ReportPath))

$appCert = $null
$exitCode = $null
$xmlReport = $null
$htmlReport = $null
$warnings = [Collections.Generic.List[string]]::new()
$errors = [Collections.Generic.List[string]]::new()

if ($ExistingReportPath) {
    $existingReport = (Resolve-Path -LiteralPath $ExistingReportPath).ProviderPath
    $existingExtension = [IO.Path]::GetExtension($existingReport).ToLowerInvariant()
    if ($existingExtension -eq ".xml") {
        $xmlReport = Read-WackXmlReport $existingReport
        if ($null -eq $xmlReport) {
            throw "'$existingReport' is not a WACK XML report."
        }
        $htmlReport = Find-WackHtmlReport $existingReport ([datetime]::MinValue) $xmlReport
    } elseif ($existingExtension -in $htmlExtensions) {
        $htmlReport = Read-WackHtmlReport $existingReport
        if ($null -eq $htmlReport) {
            throw "'$existingReport' is not a WACK HTML report."
        }
        $siblingXml = [IO.Path]::ChangeExtension($existingReport, ".xml")
        if (Test-Path -LiteralPath $siblingXml -PathType Leaf) {
            $candidate = Read-WackXmlReport $siblingXml
            if ($null -ne $candidate -and (Test-ReportPair $candidate $htmlReport)) {
                $xmlReport = $candidate
            } else {
                $warnings.Add(
                    "Ignored '$siblingXml' because it is not the XML report for this HTML report."
                )
            }
        }
    } else {
        throw "ExistingReportPath must be a WACK .xml, .htm, or .html report."
    }
} else {
    if (-not (Test-IsAdministrator)) {
        throw (
            "The WACK command-line runner requires an elevated PowerShell process. " +
            "Ask the user to open an elevated terminal, then run this script there. " +
            "Do not attempt to approve the operating-system authentication prompt."
        )
    }

    $appCert = Resolve-AppCert
    & $appCert reset | Out-Host
    if ($LASTEXITCODE -ne 0) {
        throw "appcert.exe reset failed with exit code $LASTEXITCODE."
    }

    # Reports older than this run are ignored. The margin covers file-system
    # timestamp granularity.
    $startedAt = (Get-Date).AddSeconds(-5)
    & $appCert test `
        -appxpackagepath $resolvedPackage `
        -reportoutputpath $xmlPath |
        Out-Host
    $exitCode = $LASTEXITCODE

    # After a failed run, look for a report once instead of waiting for one.
    $waitSeconds = if ($exitCode -eq 0) { $ReportWaitSeconds } else { 0 }
    $deadline = (Get-Date).AddSeconds($waitSeconds)
    $htmlGraceStarted = $false
    while ($true) {
        # An interim report can still be replaced by the final one.
        if ($null -eq $xmlReport -or $xmlReport.OverallResult -eq "INCOMPLETE") {
            $latestXmlReport = Find-WackXmlReport $xmlPath $startedAt
            if ($null -ne $latestXmlReport) {
                $xmlReport = $latestXmlReport
            }
        }
        $htmlSearchPath = if ($null -ne $xmlReport) { $xmlReport.Path } else { $xmlPath }
        $htmlReport = Find-WackHtmlReport $htmlSearchPath $startedAt $xmlReport -SearchKitDirectory
        $xmlFinished = $null -ne $xmlReport -and $xmlReport.OverallResult -ne "INCOMPLETE"
        if ($xmlFinished -and $null -ne $htmlReport) {
            break
        }
        if ($xmlFinished -and -not $htmlGraceStarted) {
            # The XML report decides the result, so the HTML report only gets
            # a short grace period.
            $htmlGraceStarted = $true
            $graceDeadline = (Get-Date).AddSeconds(10)
            if ($graceDeadline -lt $deadline) {
                $deadline = $graceDeadline
            }
        }
        if ((Get-Date) -ge $deadline) {
            break
        }
        Start-Sleep -Seconds 2
    }

    if ($null -eq $xmlReport -and $null -eq $htmlReport) {
        if ($exitCode -ne 0) {
            throw "appcert.exe test failed with exit code $exitCode and produced no report."
        }
        throw (
            "WACK returned success but no report was found at '$xmlPath' or under " +
            "'$kitDirectory'."
        )
    }
    if ($exitCode -ne 0) {
        $errors.Add("appcert.exe test exited with code $exitCode.")
    }
}

if ($null -ne $xmlReport) {
    $overallResult = $xmlReport.OverallResult
    $resultSource = "xml"
    $reportAppName = $xmlReport.AppName
    $reportAppVersion = $xmlReport.AppVersion
    $nonPassingTests = @($xmlReport.NonPassingTests)
    if ($xmlReport.PartialRun) {
        $errors.Add("Not all WACK tests ran (PARTIAL_RUN is TRUE).")
    }
    if (-not $xmlReport.KitIsLatest) {
        $warnings.Add(
            "WACK $($xmlReport.KitVersion) is not the latest version, and WACK warns " +
            "that the Store submission may fail. Update the kit and rerun it."
        )
    }
    if ($null -eq $htmlReport) {
        $warnings.Add(
            "No HTML report from the same WACK run was found. The XML report decides the result."
        )
    } elseif ($htmlReport.Recognized -and $htmlReport.OverallResult -ne $overallResult) {
        $errors.Add(
            "The XML report says $overallResult, but the HTML report says " +
            "$($htmlReport.OverallResult)."
        )
    }
} else {
    $overallResult = $htmlReport.OverallResult
    $resultSource = "html"
    $reportAppName = $null
    $reportAppVersion = $htmlReport.AppVersion
    $nonPassingTests = @()
    $warnings.Add(
        "No XML report was found. The result comes from the HTML report, and " +
        "individual test results are not listed."
    )
}

if ($overallResult -ne "PASSED") {
    $errors.Add("WACK overall result is '$overallResult'.")
}

$optionalFailures = @($nonPassingTests | Where-Object { $_.optional }).Count
if ($optionalFailures) {
    $warnings.Add(
        "$optionalFailures optional test(s) did not pass. Optional tests do not " +
        "block Store onboarding, but investigate them."
    )
}
$requiredFailures = $nonPassingTests.Count - $optionalFailures
if ($requiredFailures) {
    $warnings.Add(
        "$requiredFailures required test(s) did not pass. Review nonPassingTests."
    )
}

$packageIdentity = $null
try {
    $packageIdentity = Get-PackageIdentity $resolvedPackage
    if ($null -eq $packageIdentity) {
        $warnings.Add(
            "Only .msix and .appx packages are matched to the report. Check the " +
            "report's app name and version by hand."
        )
    }
} catch {
    $warnings.Add(
        "The package identity could not be read, so the report was not matched " +
        "to the package: $($_.Exception.Message)"
    )
}
if ($null -ne $packageIdentity) {
    if ($reportAppName -and $reportAppName -ne $packageIdentity.Name) {
        $errors.Add(
            "The report is for '$reportAppName', but the package identity is " +
            "'$($packageIdentity.Name)'."
        )
    }
    if (-not $reportAppVersion) {
        $warnings.Add(
            "The report's app version could not be read, so it was not matched " +
            "to the package version."
        )
    } elseif ($reportAppVersion -ne $packageIdentity.Version) {
        $errors.Add(
            "The report is for version $reportAppVersion, but the package is " +
            "version $($packageIdentity.Version)."
        )
    }
}

$savedXmlReport = if ($null -ne $xmlReport) {
    Save-Report $xmlReport.Path $xmlPath
} else {
    $null
}
$savedHtmlReport = if ($null -ne $htmlReport) {
    Save-Report $htmlReport.Path $htmlPath
} else {
    $null
}
$unsavedPaths = @()
if ($null -eq $savedXmlReport) {
    $unsavedPaths += $xmlPath
}
if ($null -eq $savedHtmlReport) {
    $unsavedPaths += $htmlPath
}
foreach ($unsavedPath in $unsavedPaths) {
    if (Test-Path -LiteralPath $unsavedPath -PathType Leaf) {
        $warnings.Add(
            "'$unsavedPath' already existed and was not replaced. It is not part " +
            "of this result."
        )
    }
}

$result = [ordered]@{
    package = $resolvedPackage
    packageSha256 = (
        Get-FileHash -LiteralPath $resolvedPackage -Algorithm SHA256
    ).Hash.ToLowerInvariant()
    packageIdentityName = if ($null -ne $packageIdentity) { $packageIdentity.Name } else { $null }
    packageVersion = if ($null -ne $packageIdentity) { $packageIdentity.Version } else { $null }
    appCert = $appCert
    exitCode = $exitCode
    overallResult = $overallResult
    resultSource = $resultSource
    reportAppName = $reportAppName
    reportAppVersion = $reportAppVersion
    kitVersion = if ($null -ne $xmlReport) { $xmlReport.KitVersion } else { $null }
    reportTime = if ($null -ne $xmlReport) { $xmlReport.ReportTime } else { $null }
    xmlReport = $savedXmlReport
    htmlReport = $savedHtmlReport
    sourceXmlReport = if ($null -ne $xmlReport) { $xmlReport.Path } else { $null }
    sourceHtmlReport = if ($null -ne $htmlReport) { $htmlReport.Path } else { $null }
    nonPassingTests = $nonPassingTests
    warnings = @($warnings)
    errors = @($errors)
    passed = $errors.Count -eq 0
}
$result | ConvertTo-Json -Depth 6

if ($errors.Count) {
    $reviewPath = if ($savedXmlReport) { $savedXmlReport } else { $savedHtmlReport }
    throw "WACK verification failed. $($errors -join ' ') Review '$reviewPath'."
}
package.json 0.1 KB
{
  "name": "microsoft-store-publisher-tools",
  "private": true,
  "type": "module",
  "dependencies": {
    "playwright-core": "1.63.0"
  }
}
partner-center-control.mjs 19.2 KB
import { existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { chromium } from "playwright-core";

const writeCommands = new Set([
  "add-combo-token",
  "check-label",
  "check-name",
  "click-role",
  "click-text",
  "click-text-ancestor",
  "click-text-index",
  "fill-label",
  "fill-selector",
  "select-combo",
  "uncheck-label",
  "upload-file",
  "upload-file-index",
]);

const edgeCandidates = [
  process.env.PARTNER_CENTER_EDGE_PATH,
  path.join(
    process.env["ProgramFiles(x86)"] ?? "",
    "Microsoft",
    "Edge",
    "Application",
    "msedge.exe",
  ),
  path.join(
    process.env.ProgramFiles ?? "",
    "Microsoft",
    "Edge",
    "Application",
    "msedge.exe",
  ),
].filter(Boolean);

const edgePath = edgeCandidates.find((candidate) => existsSync(candidate));
const profilePath =
  process.env.PARTNER_CENTER_PROFILE ??
  path.join(os.homedir(), ".copilot", "browser-profiles", "partner-center");
const port = Number.parseInt(process.env.PARTNER_CENTER_DEBUG_PORT ?? "9223", 10);
const endpoint =
  process.env.PARTNER_CENTER_CDP_ENDPOINT ?? `http://127.0.0.1:${port}`;

function requireWriteAccess(command) {
  if (
    writeCommands.has(command) &&
    process.env.PARTNER_CENTER_ALLOW_WRITE !== "1"
  ) {
    throw new Error(
      `${command} can change Partner Center state. Obtain the required user approval, ` +
        "then set PARTNER_CENTER_ALLOW_WRITE=1 for this one action.",
    );
  }
}

function pageSummary(page, index) {
  return {
    index,
    title: "",
    url: page.url(),
  };
}

async function listPages(browser) {
  const pages = browser.contexts().flatMap((context) => context.pages());
  return Promise.all(
    pages.map(async (page, index) => ({
      ...pageSummary(page, index),
      title: await page.title().catch(() => ""),
    })),
  );
}

async function activePage(browser) {
  const pages = browser.contexts().flatMap((context) => context.pages());
  const configuredIndex = process.env.PARTNER_CENTER_PAGE_INDEX;
  if (configuredIndex !== undefined) {
    const page = pages[Number.parseInt(configuredIndex, 10)];
    if (!page) {
      throw new Error(
        `PARTNER_CENTER_PAGE_INDEX=${configuredIndex} does not identify an open page.`,
      );
    }
    return page;
  }

  const preferred = pages.filter(
    (page) =>
      page.url().includes("partner.microsoft.com") ||
      page.url().includes("storedeveloper.microsoft.com"),
  );
  const page =
    preferred.at(-1) ??
    pages.filter((candidate) => !candidate.url().startsWith("edge://")).at(-1) ??
    pages.at(-1);
  if (!page) {
    throw new Error("No browser page is available.");
  }
  return page;
}

async function connect() {
  const browser = await chromium.connectOverCDP(endpoint);
  return { browser, page: await activePage(browser) };
}

function commandHelp() {
  return {
    readOnly: [
      "status",
      "pages",
      "goto <url>",
      "reload",
      "text",
      "anchors",
      "overview-state",
      "agreement-state",
      "inputs",
      "radios",
      "comboboxes",
      "combo-options <index>",
      "button-state <name>",
      "find-text <text>",
      "match-context <text>",
      "ancestors <text>",
      "screenshot <path>",
    ],
    guardedWrites: [...writeCommands].sort(),
    environment: {
      PARTNER_CENTER_PROFILE: profilePath,
      PARTNER_CENTER_CDP_ENDPOINT: endpoint,
      PARTNER_CENTER_PAGE_INDEX: "optional zero-based page index",
      PARTNER_CENTER_ALLOW_WRITE: "set to 1 only for an approved mutation",
    },
  };
}

const [command = "help", ...args] = process.argv.slice(2);
requireWriteAccess(command);

if (command === "help") {
  console.log(JSON.stringify(commandHelp(), null, 2));
  process.exit(0);
}

if (command === "launch") {
  if (!edgePath) {
    throw new Error(
      "Microsoft Edge was not found. Set PARTNER_CENTER_EDGE_PATH to msedge.exe.",
    );
  }
  const context = await chromium.launchPersistentContext(profilePath, {
    executablePath: edgePath,
    headless: false,
    args: [
      `--remote-debugging-port=${port}`,
      "--no-first-run",
      "--start-maximized",
    ],
  });
  const page = context.pages()[0] ?? (await context.newPage());
  await page.goto("https://storedeveloper.microsoft.com", {
    waitUntil: "domcontentloaded",
    timeout: 120000,
  });
  console.log(
    JSON.stringify({
      ready: true,
      url: page.url(),
      profilePath,
      endpoint,
    }),
  );
  await new Promise(() => {});
}

const { browser, page } = await connect();
try {
  if (command === "status") {
    console.log(
      JSON.stringify({ title: await page.title(), url: page.url() }),
    );
  } else if (command === "pages") {
    console.log(JSON.stringify(await listPages(browser)));
  } else if (command === "goto") {
    const url = args[0];
    if (!url) {
      throw new Error("goto requires a URL.");
    }
    await page.goto(url, {
      waitUntil: "domcontentloaded",
      timeout: 120000,
    });
    console.log(
      JSON.stringify({ title: await page.title(), url: page.url() }),
    );
  } else if (command === "reload") {
    await page.reload({ waitUntil: "domcontentloaded", timeout: 120000 });
    console.log(
      JSON.stringify({ title: await page.title(), url: page.url() }),
    );
  } else if (command === "text") {
    await page.waitForLoadState("domcontentloaded");
    console.log((await page.locator("body").innerText()).slice(0, 50000));
  } else if (command === "anchors") {
    console.log(
      JSON.stringify(
        await page.locator("a").evaluateAll((anchors) =>
          anchors
            .map((anchor, index) => ({
              index,
              text: anchor.innerText.trim(),
              ariaLabel: anchor.getAttribute("aria-label"),
              href: anchor.href,
            }))
            .filter((anchor) => anchor.text || anchor.ariaLabel),
        ),
      ),
    );
  } else if (command === "overview-state") {
    const body = await page.locator("body").innerText();
    const submitButton = page.getByRole("button", {
      name: "Submit for certification",
      exact: true,
    });
    const submitButtonCount = await submitButton.count();
    const sectionLinks = await page.locator("a").evaluateAll((anchors) =>
      anchors
        .map((anchor) => ({
          text: anchor.innerText.trim(),
          ariaLabel: anchor.getAttribute("aria-label"),
          href: anchor.href,
        }))
        .filter(
          (anchor) =>
            anchor.ariaLabel?.includes("Status:") ||
            anchor.ariaLabel === "Submission Options",
        ),
    );
    console.log(
      JSON.stringify({
        title: await page.title(),
        url: page.url(),
        productInDraft: body.includes("In draft"),
        productInCertification: body.includes("In certification"),
        productPublished:
          body.includes("In the Store") || body.includes("Published"),
        packageValidated: body.includes("Validated"),
        sectionLinks,
        submitButton: {
          count: submitButtonCount,
          disabled: submitButtonCount
            ? await submitButton.first().isDisabled()
            : null,
        },
        bodyText: body.slice(0, 30000),
      }),
    );
  } else if (command === "agreement-state") {
    const body = await page.locator("body").innerText();
    const acceptControls = page.getByText("Accept", { exact: true });
    const acceptCount = await acceptControls.count();
    const controls = [];
    for (let index = 0; index < Math.min(acceptCount, 20); index += 1) {
      controls.push(
        await acceptControls.nth(index).evaluate((element, matchIndex) => ({
          index: matchIndex,
          tag: element.tagName,
          testid: element.getAttribute("testid"),
          outerHTML: element.outerHTML.slice(0, 1200),
        }), index),
      );
    }
    console.log(
      JSON.stringify({
        title: await page.title(),
        url: page.url(),
        notAccepted: body.includes("Not Accepted"),
        acceptCount,
        controls,
        bodyText: body.slice(0, 30000),
      }),
    );
  } else if (command === "click-text") {
    const text = args.join(" ");
    const target = page.getByText(text, { exact: true }).first();
    await target.waitFor({ state: "visible", timeout: 30000 });
    await target.click();
    await page.waitForTimeout(1500);
    console.log(
      JSON.stringify({ title: await page.title(), url: page.url() }),
    );
  } else if (command === "click-role") {
    const [role, ...nameParts] = args;
    const name = nameParts.join(" ");
    const target = page.getByRole(role, { name, exact: true }).first();
    await target.waitFor({ state: "visible", timeout: 30000 });
    await target.click();
    await page.waitForTimeout(1500);
    console.log(
      JSON.stringify({ title: await page.title(), url: page.url() }),
    );
  } else if (command === "click-text-index") {
    const index = Number.parseInt(args[0], 10);
    const text = args.slice(1).join(" ");
    const target = page.getByText(text, { exact: true }).nth(index);
    await target.waitFor({ state: "visible", timeout: 30000 });
    await target.click();
    await page.waitForTimeout(1500);
    console.log(
      JSON.stringify({
        title: await page.title(),
        url: page.url(),
        index,
        text,
      }),
    );
  } else if (command === "click-text-ancestor") {
    const text = args.join(" ");
    const target = page.getByText(text, { exact: true }).first();
    await target.waitFor({ state: "visible", timeout: 30000 });
    const ancestor = target.locator(
      "xpath=ancestor-or-self::*[self::a or self::button or self::he-button or " +
        "self::accexp_he-button or @role='button'][1]",
    );
    await ancestor.click();
    await page.waitForTimeout(1500);
    console.log(
      JSON.stringify({ title: await page.title(), url: page.url() }),
    );
  } else if (command === "fill-label") {
    const [label, ...valueParts] = args;
    await page
      .getByLabel(label, { exact: true })
      .fill(valueParts.join(" "));
    console.log(JSON.stringify({ label, filled: true }));
  } else if (command === "fill-selector") {
    const [selector, ...valueParts] = args;
    await page.locator(selector).first().fill(valueParts.join(" "));
    console.log(JSON.stringify({ selector, filled: true }));
  } else if (command === "check-label") {
    const label = args.join(" ");
    await page.getByLabel(label, { exact: true }).first().check();
    console.log(JSON.stringify({ label, checked: true }));
  } else if (command === "uncheck-label") {
    const label = args.join(" ");
    await page.getByLabel(label, { exact: true }).first().uncheck();
    console.log(JSON.stringify({ label, checked: false }));
  } else if (command === "upload-file") {
    const filePath = args.join(" ");
    await page.locator("input[type='file']").first().setInputFiles(filePath);
    await page.waitForTimeout(2000);
    console.log(JSON.stringify({ filePath, url: page.url() }));
  } else if (command === "upload-file-index") {
    const index = Number.parseInt(args[0], 10);
    const filePaths = args.slice(1);
    await page
      .locator("input[type='file']")
      .nth(index)
      .setInputFiles(filePaths);
    await page.waitForTimeout(2000);
    console.log(JSON.stringify({ index, filePaths, url: page.url() }));
  } else if (command === "check-name") {
    const name = args.join(" ");
    await page.getByLabel("Name", { exact: true }).fill(name);
    await page.getByText("Check availability", { exact: true }).click();
    await page.waitForTimeout(1000);
    const body = await page.locator("body").innerText();
    console.log(
      JSON.stringify({
        name,
        result: body.includes("The name is not available.")
          ? "unavailable"
          : body.includes("available")
            ? "available"
            : "unknown",
      }),
    );
  } else if (command === "button-state") {
    const name = args.join(" ");
    const button = page.getByRole("button", { name, exact: true });
    const count = await button.count();
    console.log(
      JSON.stringify({
        name,
        count,
        disabled: count ? await button.first().isDisabled() : null,
      }),
    );
  } else if (command === "find-text") {
    const text = args.join(" ");
    const matches = page.getByText(text, { exact: true });
    const count = await matches.count();
    const elements = [];
    for (let index = 0; index < Math.min(count, 20); index += 1) {
      elements.push(
        await matches.nth(index).evaluate((element, matchIndex) => ({
          index: matchIndex,
          tag: element.tagName,
          role: element.getAttribute("role"),
          testid: element.getAttribute("testid"),
          href: element.getAttribute("href"),
          disabled: "disabled" in element ? element.disabled : null,
          outerHTML: element.outerHTML.slice(0, 1000),
        }), index),
      );
    }
    console.log(JSON.stringify({ text, count, elements }));
  } else if (command === "match-context") {
    const text = args.join(" ");
    const matches = page.getByText(text, { exact: true });
    const count = await matches.count();
    const elements = [];
    for (let index = 0; index < Math.min(count, 20); index += 1) {
      elements.push(
        await matches.nth(index).evaluate((element, matchIndex) => {
          let current = element;
          for (let depth = 0; current && depth < 10; depth += 1) {
            const value = current.innerText?.trim();
            if (
              value &&
              value !== element.innerText?.trim() &&
              value.length < 4000
            ) {
              return {
                index: matchIndex,
                tag: element.tagName,
                contextTag: current.tagName,
                text: value,
                outerHTML: current.outerHTML.slice(0, 2500),
              };
            }
            current = current.parentElement;
          }
          return {
            index: matchIndex,
            tag: element.tagName,
            text: element.innerText?.trim(),
          };
        }, index),
      );
    }
    console.log(JSON.stringify({ text, count, elements }));
  } else if (command === "inputs") {
    console.log(
      JSON.stringify(
        await page
          .locator("input, textarea, he-checkbox, [role='checkbox']")
          .evaluateAll((elements) =>
            elements.map((element, index) => ({
              index,
              tag: element.tagName,
              type: element.getAttribute("type"),
              name: element.getAttribute("name"),
              id: element.id,
              ariaLabel: element.getAttribute("aria-label"),
              value:
                "value" in element
                  ? element.value
                  : element.getAttribute("value"),
              checked:
                "checked" in element
                  ? element.checked
                  : element.getAttribute("checked"),
              disabled:
                "disabled" in element
                  ? element.disabled
                  : element.hasAttribute("disabled"),
              outerHTML: element.outerHTML.slice(0, 1200),
            })),
          ),
      ),
    );
  } else if (command === "radios") {
    console.log(
      JSON.stringify(
        await page.locator("input[type='radio']").evaluateAll((radios) =>
          radios.map((radio, index) => ({
            index,
            name: radio.getAttribute("name"),
            value: radio.value,
            checked: radio.checked,
            title: radio.getAttribute("title"),
            parentText: radio.parentElement?.innerText,
            labels: (radio.getAttribute("aria-labelledby") ?? "")
              .split(/\s+/)
              .filter(Boolean)
              .map((id) => document.getElementById(id)?.innerText)
              .filter(Boolean),
          })),
        ),
      ),
    );
  } else if (command === "comboboxes") {
    console.log(
      JSON.stringify(
        await page
          .locator("[role='combobox'], select")
          .evaluateAll((elements) =>
            elements.map((element, index) => ({
              index,
              tag: element.tagName,
              ariaLabel: element.getAttribute("aria-label"),
              placeholder: element.getAttribute("placeholder"),
              value: "value" in element ? element.value : null,
              disabled:
                "disabled" in element
                  ? element.disabled
                  : element.hasAttribute("disabled"),
              parentText: element.parentElement?.parentElement?.innerText?.slice(
                0,
                1000,
              ),
              outerHTML: element.outerHTML.slice(0, 1500),
            })),
          ),
      ),
    );
  } else if (command === "combo-options") {
    const index = Number.parseInt(args[0], 10);
    const combo = page.locator("[role='combobox'], select").nth(index);
    await combo.click();
    await page.waitForTimeout(500);
    console.log(
      JSON.stringify({
        index,
        options: await page.getByRole("option").evaluateAll((options) =>
          options
            .filter((option) => option.getClientRects().length > 0)
            .map((option) => ({
              text: option.innerText,
              value: option.getAttribute("value"),
              selected:
                option.getAttribute("aria-selected") ?? option.selected,
            })),
        ),
      }),
    );
  } else if (command === "select-combo") {
    const index = Number.parseInt(args[0], 10);
    const optionText = args.slice(1).join(" ");
    const combo = page.locator("[role='combobox'], select").nth(index);
    if ((await combo.evaluate((element) => element.tagName)) === "SELECT") {
      await combo.selectOption({ label: optionText });
    } else {
      await combo.click();
      await page
        .getByRole("option", { name: optionText, exact: true })
        .click();
    }
    console.log(JSON.stringify({ index, optionText }));
  } else if (command === "add-combo-token") {
    const index = Number.parseInt(args[0], 10);
    const token = args.slice(1).join(" ");
    const combo = page.locator("[role='combobox']").nth(index);
    await combo.fill(token);
    await combo.press("Enter");
    console.log(JSON.stringify({ index, token }));
  } else if (command === "ancestors") {
    const text = args.join(" ");
    const target = page.getByText(text, { exact: true }).first();
    await target.waitFor({ state: "attached", timeout: 30000 });
    console.log(
      JSON.stringify(
        await target.evaluate((element) => {
          const ancestors = [];
          let current = element;
          for (let index = 0; current && index < 10; index += 1) {
            ancestors.push({
              tag: current.tagName,
              role: current.getAttribute("role"),
              testid: current.getAttribute("testid"),
              className: current.className,
              href: current.getAttribute("href"),
              outerHTML: current.outerHTML.slice(0, 1500),
            });
            current = current.parentElement;
          }
          return ancestors;
        }),
      ),
    );
  } else if (command === "screenshot") {
    const output = args[0];
    if (!output) {
      throw new Error("screenshot requires an output path.");
    }
    await page.screenshot({ path: output, fullPage: true });
    console.log(JSON.stringify({ output }));
  } else {
    throw new Error(`Unknown command: ${command}`);
  }
} finally {
  await browser.close();
}
templates/
certification-notes.md 1.3 KB
# Certification notes template

## Test environment

- App: [PRODUCT_NAME]
- Package version: [PACKAGE_VERSION]
- Architecture: [ARCHITECTURE]
- Minimum Windows version: [MINIMUM_WINDOWS_VERSION]
- Required hardware: [HARDWARE_OR_NONE]
- Required network: [NETWORK_REQUIREMENTS_OR_NONE]
- Credentials: [NOT_REQUIRED_OR_ACCOUNT_DETAILS_PROVIDED_SEPARATELY]

## Primary test flow

1. Install and launch [PRODUCT_NAME].
2. [FIRST_SETUP_STEP].
3. [CONNECTION_OR_INPUT_STEP].
4. [PAIRING_OR_AUTHORIZATION_STEP].
5. Verify [PRIMARY_EXPECTED_RESULT].
6. Verify [SECONDARY_EXPECTED_RESULT].

## Windows integration

- Startup behavior: [HOW_TO_ENABLE_AND_VERIFY].
- Tray behavior: [HOW_TO_VERIFY].
- Full-screen behavior: [HOW_TO_VERIFY_OR_NOT_APPLICABLE].
- Notifications: [HOW_TO_VERIFY_OR_NOT_APPLICABLE].
- Helper process: [PURPOSE_AND_EXPECTED_LIFECYCLE].

## Network and firewall behavior

[PORTS_PROTOCOLS_DISCOVERY_MULTICAST_FIREWALL_DETAILS_OR_NOT_APPLICABLE]

## Known limitations

- [PROTECTED_OR_DRM_CONTENT_LIMITATION]
- [HARDWARE_OR_NETWORK_LIMITATION]
- [OTHER_VERIFIED_LIMITATION]

## Privacy and data

[LOCAL_STORAGE_NETWORK_TRANSFER_TELEMETRY_AND_CLOUD_BEHAVIOR]

## Troubleshooting

- Logs: [LOG_LOCATION_OR_NOT_AVAILABLE]
- Recovery: [RESTART_REPAIR_OR_RESET_STEPS]
- Support: [SUPPORT_URL]

## Attribution

[TRADEMARK_ATTRIBUTION_AND_NON_AFFILIATION_TEXT_IF_REQUIRED]
run-full-trust-rationale.md 1.1 KB
# runFullTrust rationale template

Replace every bracketed field with verified product-specific information.

> [PRODUCT_NAME] is a packaged [DESKTOP_TECHNOLOGY] desktop application. It
> requires `runFullTrust` to [PRIMARY_NATIVE_PURPOSE]. The application uses
> [WINDOWS_APIS_OR_SUBSYSTEMS] for [BEHAVIOR]. It
> [LAUNCHES/DOES_NOT_LAUNCH] [HELPER_PROCESS_DESCRIPTION] to
> [HELPER_PURPOSE], and uses [STARTUP_OR_TRAY_INTEGRATION] for
> [USER_VISIBLE_BEHAVIOR]. It does not require elevation and does not install
> drivers or services. It does not change system-wide settings
> [AND_DOES/DOES_NOT] use a cloud backend. All full-trust behavior runs in the
> signed application package under the interactive user's account.

Checklist:

- Name the exact desktop technology.
- Name the native Windows APIs or subsystems.
- Explain every helper executable or self-spawned worker mode.
- Explain startup-task and tray behavior.
- State whether elevation is used.
- State whether services or drivers are installed.
- State whether system-wide settings are changed.
- State whether a cloud service is used.
- Keep the statement factual and consistent with the package.
store-listing.md 1.3 KB
# Store listing worksheet

## Product identity

- Reserved product name:
- Short title:
- Developer name:
- Primary category:
- Secondary category:

## Short description

[One concrete sentence naming the input, action, and result.]

## Description

### What it does

[Specific product behavior.]

### How to use it

1. [Step]
2. [Step]
3. [Step]

### Privacy

[What stays local, what is transmitted, what is stored, and whether telemetry
or a cloud service is used.]

### Requirements

[Windows version, architecture, network, hardware, accounts, or companion
devices.]

### Limitations

[DRM, protected content, unsupported formats, network isolation, enterprise
policy, or hardware limitations.]

### License and source

[License, notices, and corresponding-source link.]

### Trademark attribution

[Trademark owner and non-affiliation statement when required.]

## Features

- [Specific behavior]
- [Specific behavior]
- [Specific behavior]
- [Specific behavior]
- [Specific behavior]
- [Specific behavior]

## Keywords

- [Keyword]
- [Keyword]
- [Keyword]
- [Keyword]
- [Keyword]
- [Keyword]
- [Keyword]

## URLs

- Privacy policy:
- Website:
- Support:
- Source:

## Screenshots

- [Genuine application state and caption]
- [Genuine application state and caption]
- [Genuine application state and caption]
submission-record.json 1.1 KB
{
  "product": {
    "name": "",
    "productId": "",
    "storeUrl": "",
    "partnerCenterOverviewUrl": ""
  },
  "identity": {
    "packageIdentityName": "",
    "publisher": "",
    "publisherDisplayName": "",
    "packageFamilyName": "",
    "reservedDisplayName": ""
  },
  "package": {
    "applicationVersion": "",
    "packageVersion": "",
    "architecture": "",
    "minimumWindowsVersion": "",
    "path": "",
    "sha256": "",
    "signed": false,
    "validatedByPartnerCenter": false
  },
  "wack": {
    "result": "",
    "reportPath": "",
    "kitVersion": "",
    "testedAt": ""
  },
  "submission": {
    "submissionId": "",
    "pricingAndAvailability": "",
    "properties": "",
    "ageRatings": "",
    "packages": "",
    "storeListings": "",
    "submissionOptions": "",
    "additionalTestingInformation": "",
    "agreementName": "",
    "agreementVersion": "",
    "agreementAcceptedAt": "",
    "publishingMode": "",
    "status": "",
    "pipelineStage": "",
    "submittedAt": "",
    "lastCheckedAt": ""
  },
  "risks": [],
  "certificationFindings": [],
  "notes": []
}

License (MIT)

View full license text
MIT License

Copyright GitHub, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.