diff --git a/.gitignore b/.gitignore index d962b820..0919842a 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,10 @@ dist # Stores VSCode versions used for testing VSCode extensions .vscode-test +# Playwright test artifacts +test-results/ +playwright-report/ + # yarn v3 .pnp.* .yarn/* @@ -208,6 +212,7 @@ CMakeCache.txt # Development certificate devcert.pfx *.msix +*.vsix # GUI app package outputs /src/winapp-GUI/winapp-GUI/AppPackages diff --git a/src/winapp-VSC/.vscodeignore b/src/winapp-VSC/.vscodeignore index e9481391..66ea5749 100644 --- a/src/winapp-VSC/.vscodeignore +++ b/src/winapp-VSC/.vscodeignore @@ -13,3 +13,8 @@ package-lock.json **/*.ts **/*.map **/*.backup +playwright-report/** +test-results/** +playwright.config.ts +PR_REVIEW.md +EDITOR_SUPPORT.md diff --git a/src/winapp-VSC/README.md b/src/winapp-VSC/README.md index 9181f5a4..58e64dcf 100644 --- a/src/winapp-VSC/README.md +++ b/src/winapp-VSC/README.md @@ -117,6 +117,31 @@ The extension provides a **custom `winapp` debug type** that launches your app w | `args` | string | | Command-line arguments to pass to the application. | | `outputAppxDirectory` | string | | Output directory for the loose-layout package. Defaults to an `AppX` folder inside the input folder. | +### AppxManifest Visual Editor + +The extension includes a **visual editor** for `AppxManifest.xml` and `.appxmanifest` files. Instead of hand-editing XML, you get a form-based UI organized into tabs: + +| Tab | What you can edit | +|-----|-------------------| +| **Identity** | Package name, publisher, version, processor architecture, phone identity (optional), and resource ID | +| **Properties** | Display name, publisher display name, description, and store logo path | +| **Dependencies** | Target device families (min/max versions), package dependencies, main package dependencies, driver constraints, OS package dependencies, host runtime dependencies, and external dependencies | +| **Resources** | BCP-47 language declarations (e.g. `en-us`, `fr-fr`) | +| **Capabilities** | General, restricted, device, and custom capabilities (e.g. Internet Client, Run Full Trust, Microphone) | +| **Applications** | Application entries including executable path, entry point, trust level, runtime behavior, visual elements (logos, splash screen, tile options), and extensions (protocol activation, COM servers, background tasks, file type associations, app services, and more) | + +**Key features:** + +- **Real-time validation** — inline errors for required fields, format rules (publisher DN, version, GUIDs, BCP-47, hex colors), and extension field requirements +- **Asset generation** — "Regenerate Assets" button invokes the CLI to auto-generate all icon sizes from a single source image +- **Extension management** — add/remove typed extensions (Protocol Activation, COM Server, Background Tasks, File Type Association, App Execution Alias, Startup Task, Share Target, App Service, Toast Notification Activation, MCP Server) with pre-filled templates +- **Reorderable lists** — drag dependencies and resources up/down to control XML element order +- **Format-preserving edits** — changes are applied surgically to the XML text, preserving your whitespace, comments, and attribute ordering + +**How to open:** + +When you open an `AppxManifest.xml` or `.appxmanifest` file, VS Code will offer the visual editor as an option alongside the default text editor. You can switch between them at any time by right clicking on the file and selecting the **Open With…** command. + ## Scenarios ### Initialize and set up a project diff --git a/src/winapp-VSC/package-lock.json b/src/winapp-VSC/package-lock.json index 5a9d3d01..daa3f648 100644 --- a/src/winapp-VSC/package-lock.json +++ b/src/winapp-VSC/package-lock.json @@ -8,17 +8,20 @@ "name": "winapp", "version": "0.1.1", "dependencies": { + "@xmldom/xmldom": "^0.9.9", "glob": "^13.0.6" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@types/mocha": "^10.0.10", "@types/node": "22.x", "@types/vscode": "^1.109.0", "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5.2", - "@vscode/vsce": "^3.3.2", + "@vscode/vsce": "2.22.0", "esbuild": "^0.27.3", "eslint": "^10.0.2", + "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1" }, @@ -26,216 +29,6 @@ "vscode": "^1.109.0" } }, - "node_modules/@azu/format-text": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", - "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@azu/style-format": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", - "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", - "dev": true, - "license": "WTFPL", - "dependencies": { - "@azu/format-text": "^1.0.1" - } - }, - "node_modules/@azure/abort-controller": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", - "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-auth": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", - "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-util": "^1.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-client": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", - "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-rest-pipeline": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz", - "integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-tracing": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", - "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-util": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", - "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/identity": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", - "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.9.0", - "@azure/core-client": "^1.9.2", - "@azure/core-rest-pipeline": "^1.17.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.11.0", - "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^5.5.0", - "@azure/msal-node": "^5.1.0", - "open": "^10.1.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/logger": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", - "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/msal-browser": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.9.0.tgz", - "integrity": "sha512-CzE+4PefDSJWj26zU7G1bKchlGRRHMBFreG4tAlGuzyI8hAPiYGobaJvZBgZBf6L63iphX7VH+ityL8VgEQz9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/msal-common": "16.5.2" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-common": { - "version": "16.5.2", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.5.2.tgz", - "integrity": "sha512-GkDEL6TYo3HgT3UuqakdgE9PZfc1hMki6+Hwgy1uddb/EauvAKfu85vVhuofRSo22D1xTnWt8Ucwfg4vSCVwvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-node": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.1.5.tgz", - "integrity": "sha512-ObTeMoNPmq19X3z40et9Xvs4ZoWVeJg43PZMRLG5iwVL+2nCtAerG3YTDItqPp1CfXNwmCXBbg8jn1DOx65c3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/msal-common": "16.5.2", - "jsonwebtoken": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", @@ -959,44 +752,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1008,457 +763,125 @@ "node": ">=14" } }, - "node_modules/@secretlint/config-creator": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", - "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@secretlint/types": "^10.2.2" + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" }, "engines": { - "node": ">=20.0.0" + "node": ">=18" } }, - "node_modules/@secretlint/config-loader": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", - "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/profiler": "^10.2.2", - "@secretlint/resolver": "^10.2.2", - "@secretlint/types": "^10.2.2", - "ajv": "^8.17.1", - "debug": "^4.4.1", - "rc-config-loader": "^4.1.3" - }, - "engines": { - "node": ">=20.0.0" - } + "license": "MIT" }, - "node_modules/@secretlint/config-loader/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } + "license": "MIT" }, - "node_modules/@secretlint/config-loader/node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" + "license": "MIT" }, - "node_modules/@secretlint/config-loader/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, - "node_modules/@secretlint/core": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", - "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/profiler": "^10.2.2", - "@secretlint/types": "^10.2.2", - "debug": "^4.4.1", - "structured-source": "^4.0.0" - }, - "engines": { - "node": ">=20.0.0" - } + "license": "MIT" }, - "node_modules/@secretlint/formatter": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", - "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/resolver": "^10.2.2", - "@secretlint/types": "^10.2.2", - "@textlint/linter-formatter": "^15.2.0", - "@textlint/module-interop": "^15.2.0", - "@textlint/types": "^15.2.0", - "chalk": "^5.4.1", - "debug": "^4.4.1", - "pluralize": "^8.0.0", - "strip-ansi": "^7.1.0", - "table": "^6.9.0", - "terminal-link": "^4.0.0" - }, - "engines": { - "node": ">=20.0.0" + "undici-types": "~6.21.0" } }, - "node_modules/@secretlint/formatter/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "node_modules/@types/vscode": { + "version": "1.109.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz", + "integrity": "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw==", "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } + "license": "MIT" }, - "node_modules/@secretlint/node": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", - "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/config-loader": "^10.2.2", - "@secretlint/core": "^10.2.2", - "@secretlint/formatter": "^10.2.2", - "@secretlint/profiler": "^10.2.2", - "@secretlint/source-creator": "^10.2.2", - "@secretlint/types": "^10.2.2", - "debug": "^4.4.1", - "p-map": "^7.0.3" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" }, "engines": { - "node": ">=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@secretlint/profiler": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", - "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", - "dev": true, - "license": "MIT" - }, - "node_modules/@secretlint/resolver": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", - "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@secretlint/secretlint-formatter-sarif": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", - "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", - "dependencies": { - "node-sarif-builder": "^3.2.0" + "engines": { + "node": ">= 4" } }, - "node_modules/@secretlint/secretlint-rule-no-dotenv": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", - "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/types": "^10.2.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/secretlint-rule-preset-recommend": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", - "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/source-creator": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", - "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/types": "^10.2.2", - "istextorbinary": "^9.5.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/types": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", - "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@sindresorhus/merge-streams": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", - "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@textlint/ast-node-types": { - "version": "15.6.0", - "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.6.0.tgz", - "integrity": "sha512-CxZHFbYAU7J0A4izz31wV2ZZfySR6aVj2OSR6/3tppZm7VV6hM7nA7sutsLwIiBL/v4lsB1RM79l4Dc/VrH4qw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@textlint/linter-formatter": { - "version": "15.6.0", - "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.6.0.tgz", - "integrity": "sha512-IwHRhjwxs0a5t1eNAoKAdV224CDca38LyopPofXpwO/d0J75wBvzf/cBHXNl4TMsLKhYGtR83UprcLEKj/gZsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azu/format-text": "^1.0.2", - "@azu/style-format": "^1.0.1", - "@textlint/module-interop": "15.6.0", - "@textlint/resolver": "15.6.0", - "@textlint/types": "15.6.0", - "chalk": "^4.1.2", - "debug": "^4.4.3", - "js-yaml": "^4.1.1", - "lodash": "^4.18.1", - "pluralize": "^2.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "table": "^6.9.0", - "text-table": "^0.2.0" - } - }, - "node_modules/@textlint/linter-formatter/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@textlint/linter-formatter/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@textlint/linter-formatter/node_modules/pluralize": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", - "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@textlint/linter-formatter/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@textlint/linter-formatter/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@textlint/module-interop": { - "version": "15.6.0", - "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.6.0.tgz", - "integrity": "sha512-MHY6pJx9i5kOlrvUSK51887tYZjHAV2qnr6unBm7LtBLGDFo93utdYqHyWep8r9QLsilQdeijWtufJI46z4v4w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@textlint/resolver": { - "version": "15.6.0", - "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.6.0.tgz", - "integrity": "sha512-T1l2Gd3455pwtm0cTewhX/LLy3bL9z6/Fu/am+jj+jjGfXVoknYkjfkZEKrjHlA7xzay0EfUKnu//teYemLeZw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@textlint/types": { - "version": "15.6.0", - "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.6.0.tgz", - "integrity": "sha512-CvgYb1PiqF4BGyoZebGWzAJCZ4ChJAZ9gtWjpQIMKE4Xe2KlSwDA8m8MsiZIV321f5Ibx38BMjC1Z/2ZYP2GQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@textlint/ast-node-types": "15.6.0" - } - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mocha": { - "version": "10.0.10", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", - "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", - "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/normalize-package-data": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", - "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/sarif": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", - "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/vscode": { - "version": "1.109.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz", - "integrity": "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", - "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/type-utils": "8.56.1", - "@typescript-eslint/utils": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.56.1", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", - "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", - "debug": "^4.4.3" + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1638,21 +1061,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz", - "integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@vscode/test-cli": { "version": "0.0.12", "resolved": "https://registry.npmjs.org/@vscode/test-cli/-/test-cli-0.0.12.tgz", @@ -1774,205 +1182,54 @@ } }, "node_modules/@vscode/vsce": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.1.tgz", - "integrity": "sha512-MPn5p+DoudI+3GfJSpAZZraE1lgLv0LcwbH3+xy7RgEhty3UIkmUMUA+5jPTDaxXae00AnX5u77FxGM8FhfKKA==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-2.22.0.tgz", + "integrity": "sha512-8df4uJiM3C6GZ2Sx/KilSKVxsetrTBBIUb3c0W4B1EWHcddioVs5mkyDKtMNP0khP/xBILVSzlXxhV+nm2rC9A==", "dev": true, "license": "MIT", "dependencies": { - "@azure/identity": "^4.1.0", - "@secretlint/node": "^10.1.2", - "@secretlint/secretlint-formatter-sarif": "^10.1.2", - "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", - "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", - "@vscode/vsce-sign": "^2.0.0", - "azure-devops-node-api": "^12.5.0", - "chalk": "^4.1.2", + "azure-devops-node-api": "^11.0.1", + "chalk": "^2.4.2", "cheerio": "^1.0.0-rc.9", - "cockatiel": "^3.1.2", - "commander": "^12.1.0", - "form-data": "^4.0.0", - "glob": "^11.0.0", + "commander": "^6.2.1", + "glob": "^7.0.6", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", "leven": "^3.1.0", - "markdown-it": "^14.1.0", + "markdown-it": "^12.3.2", "mime": "^1.3.4", "minimatch": "^3.0.3", "parse-semver": "^1.1.1", "read": "^1.0.7", - "secretlint": "^10.1.2", "semver": "^7.5.2", - "tmp": "^0.2.3", + "tmp": "^0.2.1", "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", "xml2js": "^0.5.0", - "yauzl": "^3.2.1", + "yauzl": "^2.3.1", "yazl": "^2.2.2" }, "bin": { "vsce": "vsce" }, "engines": { - "node": ">= 20" + "node": ">= 14" }, "optionalDependencies": { "keytar": "^7.7.0" } }, - "node_modules/@vscode/vsce-sign": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz", - "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==", - "dev": true, - "hasInstallScript": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optionalDependencies": { - "@vscode/vsce-sign-alpine-arm64": "2.0.6", - "@vscode/vsce-sign-alpine-x64": "2.0.6", - "@vscode/vsce-sign-darwin-arm64": "2.0.6", - "@vscode/vsce-sign-darwin-x64": "2.0.6", - "@vscode/vsce-sign-linux-arm": "2.0.6", - "@vscode/vsce-sign-linux-arm64": "2.0.6", - "@vscode/vsce-sign-linux-x64": "2.0.6", - "@vscode/vsce-sign-win32-arm64": "2.0.6", - "@vscode/vsce-sign-win32-x64": "2.0.6" - } - }, - "node_modules/@vscode/vsce-sign-alpine-arm64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", - "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "alpine" - ] - }, - "node_modules/@vscode/vsce-sign-alpine-x64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", - "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "alpine" - ] - }, - "node_modules/@vscode/vsce-sign-darwin-arm64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", - "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@vscode/vsce-sign-darwin-x64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", - "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@vscode/vsce-sign-linux-arm": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", - "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@vscode/vsce-sign-linux-arm64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", - "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@vscode/vsce-sign-linux-x64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", - "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@vscode/vsce-sign-win32-arm64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", - "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@vscode/vsce-sign-win32-x64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", - "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@vscode/vsce/node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "node_modules/@vscode/vsce/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, "engines": { - "node": ">=18" + "node": ">=4" } }, "node_modules/@vscode/vsce/node_modules/balanced-match": { @@ -1982,63 +1239,91 @@ "dev": true, "license": "MIT" }, - "node_modules/@vscode/vsce/node_modules/glob": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "node_modules/@vscode/vsce/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@vscode/vsce/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" }, "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=4" } }, - "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "node_modules/@vscode/vsce/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.5" - }, + "color-name": "1.1.3" + } + }, + "node_modules/@vscode/vsce/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vscode/vsce/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=0.8.0" } }, - "node_modules/@vscode/vsce/node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "node_modules/@vscode/vsce/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", "dependencies": { - "@isaacs/cliui": "^9.0.0" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": "20 || >=22" + "node": "*" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@vscode/vsce/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@vscode/vsce/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2052,15 +1337,26 @@ "node": "*" } }, - "node_modules/@vscode/vsce/node_modules/minimatch/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "node_modules/@vscode/vsce/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "license": "MIT", + "engines": { + "node": ">=14.6" } }, "node_modules/acorn": { @@ -2113,22 +1409,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -2179,27 +1459,10 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/azure-devops-node-api": { - "version": "12.5.0", - "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", - "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-11.2.0.tgz", + "integrity": "sha512-XdiGPhrpaT5J8wdERRKs5g8E0Zy1pvOYTli7z9E8nmOn3YGp4FhtjhrOyFmX/8veWCwdI69mCHKJw6l+4J/bHA==", "dev": true, "license": "MIT", "dependencies": { @@ -2251,22 +1514,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/binaryextensions": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", - "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", - "dev": true, - "license": "Artistic-2.0", - "dependencies": { - "editions": "^6.21.0" - }, - "engines": { - "node": ">=4" - }, - "funding": { - "url": "https://bevry.me/fund" - } - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -2303,13 +1550,6 @@ "dev": true, "license": "ISC" }, - "node_modules/boundary": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", - "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -2378,29 +1618,6 @@ "node": "*" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/c8": { "version": "10.1.3", "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", @@ -2675,16 +1892,6 @@ "node": ">=8" } }, - "node_modules/cockatiel": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", - "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2705,27 +1912,14 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 6" } }, "node_modules/concat-map": { @@ -2834,85 +2028,32 @@ "optional": true, "dependencies": { "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "license": "MIT", + }, "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true, "license": "MIT", + "optional": true, "engines": { - "node": ">=0.4.0" + "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3015,33 +2156,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/editions": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", - "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", - "dev": true, - "license": "Artistic-2.0", - "dependencies": { - "version-range": "^4.15.0" - }, - "engines": { - "ecmascript": ">= es5", - "node": ">=4" - }, - "funding": { - "url": "https://bevry.me/fund" - } - }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -3101,19 +2215,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3147,22 +2248,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -3411,23 +2496,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3442,14 +2510,14 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "reusify": "^1.0.4" + "pend": "~1.2.0" } }, "node_modules/file-entry-cache": { @@ -3543,23 +2611,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -3568,20 +2619,12 @@ "license": "MIT", "optional": true }, - "node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } + "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", @@ -3670,6 +2713,19 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -3708,37 +2764,6 @@ "node": ">= 6" } }, - "node_modules/globby": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", - "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.3", - "ignore": "^7.0.3", - "path-type": "^6.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3782,22 +2807,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -3977,17 +2986,16 @@ "node": ">=0.8.19" } }, - "node_modules/index-to-position": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", - "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" } }, "node_modules/inherits": { @@ -4018,22 +3026,6 @@ "node": ">=8" } }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4067,25 +3059,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-interactive": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", @@ -4142,22 +3115,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -4224,24 +3181,6 @@ "node": ">=8" } }, - "node_modules/istextorbinary": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", - "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", - "dev": true, - "license": "Artistic-2.0", - "dependencies": { - "binaryextensions": "^6.11.0", - "editions": "^6.21.0", - "textextensions": "^6.11.0" - }, - "engines": { - "node": ">=4" - }, - "funding": { - "url": "https://bevry.me/fund" - } - }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -4258,13 +3197,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -4299,19 +3231,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -4319,42 +3238,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "dev": true, - "license": "MIT", - "dependencies": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -4368,29 +3251,6 @@ "setimmediate": "^1.0.5" } }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/keytar": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", @@ -4449,13 +3309,13 @@ } }, "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", + "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", "dev": true, "license": "MIT", "dependencies": { - "uc.micro": "^2.0.0" + "uc.micro": "^1.0.1" } }, "node_modules/locate-path": { @@ -4469,73 +3329,10 @@ }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true, - "license": "MIT" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/log-symbols": { "version": "4.1.0", @@ -4580,21 +3377,30 @@ } }, "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", + "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" + "entities": "~2.1.0", + "linkify-it": "^3.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" }, "bin": { - "markdown-it": "bin/markdown-it.mjs" + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/math-intrinsics": { @@ -4608,36 +3414,12 @@ } }, "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -4651,29 +3433,6 @@ "node": ">=4" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -4702,12 +3461,12 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -4957,55 +3716,6 @@ "license": "MIT", "optional": true }, - "node_modules/node-sarif-builder": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", - "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/sarif": "^2.1.7", - "fs-extra": "^11.1.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/normalize-package-data": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^7.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/normalize-package-data/node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/normalize-package-data/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -5048,7 +3758,6 @@ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "license": "ISC", - "optional": true, "dependencies": { "wrappy": "1" } @@ -5069,25 +3778,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5218,19 +3908,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -5245,24 +3922,6 @@ "dev": true, "license": "(MIT AND Zlib)" }, - "node_modules/parse-json": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", - "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parse-semver": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", @@ -5346,6 +4005,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5372,19 +4041,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-type": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", - "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -5412,14 +4068,51 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pluralize": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=4" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/prebuild-install": { @@ -5490,16 +4183,6 @@ "node": ">=6" } }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -5516,27 +4199,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -5564,19 +4226,6 @@ "rc": "cli.js" } }, - "node_modules/rc-config-loader": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", - "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "js-yaml": "^4.1.1", - "json5": "^2.2.3", - "require-from-string": "^2.0.2" - } - }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -5601,39 +4250,6 @@ "node": ">=0.8" } }, - "node_modules/read-pkg": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", - "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/normalize-package-data": "^2.4.3", - "normalize-package-data": "^6.0.0", - "parse-json": "^8.0.0", - "type-fest": "^4.6.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg/node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -5673,79 +4289,31 @@ "node": ">=0.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" - }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, "license": "MIT", "dependencies": { - "queue-microtask": "^1.2.2" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/safe-buffer": { @@ -5772,28 +4340,6 @@ "node": ">=11.0.0" } }, - "node_modules/secretlint": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", - "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/config-creator": "^10.2.2", - "@secretlint/formatter": "^10.2.2", - "@secretlint/node": "^10.2.2", - "@secretlint/profiler": "^10.2.2", - "debug": "^4.4.1", - "globby": "^14.1.0", - "read-pkg": "^9.0.1" - }, - "bin": { - "secretlint": "bin/secretlint.js" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -5985,73 +4531,6 @@ "simple-concat": "^1.0.0" } }, - "node_modules/slash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", - "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -6192,16 +4671,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/structured-source": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", - "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boundary": "^2.0.0" - } - }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -6215,139 +4684,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/supports-hyperlinks": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", - "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">=14.18" - }, - "funding": { - "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" - } - }, - "node_modules/supports-hyperlinks/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/table": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", - "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "ajv": "^8.0.1", - "lodash.truncate": "^4.4.2", - "slice-ansi": "^4.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/table/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/table/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/table/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/table/node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/table/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/table/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/table/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -6410,23 +4746,6 @@ "node": ">= 6" } }, - "node_modules/terminal-link": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", - "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "supports-hyperlinks": "^3.2.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/test-exclude": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", @@ -6521,29 +4840,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/textextensions": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", - "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", - "dev": true, - "license": "Artistic-2.0", - "dependencies": { - "editions": "^6.21.0" - }, - "engines": { - "node": ">=4" - }, - "funding": { - "url": "https://bevry.me/fund" - } - }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6628,12 +4924,25 @@ "typescript": ">=4.8.4" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, - "license": "0BSD" + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } }, "node_modules/tunnel": { "version": "0.0.6", @@ -6672,19 +4981,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-rest-client": { "version": "1.8.11", "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", @@ -6736,9 +5032,9 @@ } }, "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", "dev": true, "license": "MIT" }, @@ -6766,29 +5062,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -6828,30 +5101,6 @@ "node": ">=10.12.0" } }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/version-range": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", - "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", - "dev": true, - "license": "Artistic-2.0", - "engines": { - "node": ">=4" - }, - "funding": { - "url": "https://bevry.me/fund" - } - }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -7041,24 +5290,7 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "ISC" }, "node_modules/xml2js": { "version": "0.5.0", @@ -7192,17 +5424,14 @@ } }, "node_modules/yauzl": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.0.tgz", - "integrity": "sha512-PtGEvEP30p7sbIBJKUBjUnqgTVOyMURc4dLo9iNyAJnNIEz9pm88cCXF21w94Kg3k6RXkeZh5DHOGS0qEONvNQ==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", - "pend": "~1.2.0" - }, - "engines": { - "node": ">=12" + "fd-slicer": "~1.1.0" } }, "node_modules/yazl": { diff --git a/src/winapp-VSC/package.json b/src/winapp-VSC/package.json index 74516a89..6ded5725 100644 --- a/src/winapp-VSC/package.json +++ b/src/winapp-VSC/package.json @@ -118,6 +118,21 @@ "category": "WinApp" } ], + "customEditors": [ + { + "viewType": "winapp.manifestEditor", + "displayName": "AppxManifest Editor", + "selector": [ + { + "filenamePattern": "**/[Aa]ppx[Mm]anifest.xml" + }, + { + "filenamePattern": "**/*.appxmanifest" + } + ], + "priority": "option" + } + ], "debuggers": [ { "type": "winapp", @@ -184,6 +199,7 @@ "pretest": "npm run compile-tsc && npm run lint", "lint": "eslint src", "test": "vscode-test", + "test:e2e": "npx playwright test --config=playwright.config.ts", "clean": "node -e \"require('fs').rmSync('bin', {recursive: true, force: true}); require('fs').rmSync('out', {recursive: true, force: true}); require('fs').rmSync('dist', {recursive: true, force: true});\"", "build-cli": "npm run build-cli-x64 && npm run build-cli-arm64", "build-cli-x64": "dotnet publish ../winapp-CLI/WinApp.Cli/WinApp.Cli.csproj -c Release -r win-x64 --self-contained -o bin/win-x64", @@ -193,17 +209,20 @@ "copy-cli-arm64": "node -e \"const fs = require('fs'); const src = '../winapp-CLI/WinApp.Cli/bin/Release/net10.0-windows/win-arm64/publish'; const dest = 'bin/win-arm64'; fs.mkdirSync(dest, {recursive: true}); fs.cpSync(src, dest, {recursive: true});\"" }, "dependencies": { + "@xmldom/xmldom": "^0.9.9", "glob": "^13.0.6" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@types/mocha": "^10.0.10", "@types/node": "22.x", "@types/vscode": "^1.109.0", "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5.2", - "@vscode/vsce": "^3.3.2", + "@vscode/vsce": "2.22.0", "esbuild": "^0.27.3", "eslint": "^10.0.2", + "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1" }, @@ -211,4 +230,4 @@ "diff": "^8.0.3", "fast-uri": "3.1.0" } -} \ No newline at end of file +} diff --git a/src/winapp-VSC/playwright.config.ts b/src/winapp-VSC/playwright.config.ts new file mode 100644 index 00000000..e8eac71e --- /dev/null +++ b/src/winapp-VSC/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test'; +import * as path from 'path'; + +/** + * Playwright config for VS Code manifest editor E2E tests. + * + * Tests launch VS Code as an Electron app, open a fixture appxmanifest, + * and interact with the custom editor webview. + */ +export default defineConfig({ + testDir: path.join(__dirname, 'src', 'test', 'e2e'), + globalTeardown: path.join(__dirname, 'src', 'test', 'e2e', 'global-teardown.ts'), + timeout: 60_000, + retries: 1, + workers: 1, // serialise — only one VS Code instance at a time + reporter: [['list'], ['html', { open: 'never' }]], + use: { + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, +}); diff --git a/src/winapp-VSC/src/extension.ts b/src/winapp-VSC/src/extension.ts index 6d6428d0..707a021c 100644 --- a/src/winapp-VSC/src/extension.ts +++ b/src/winapp-VSC/src/extension.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { spawn } from 'child_process'; import { getWinappCliPath, WINAPP_CLI_CALLER_VALUE } from './winapp-cli-utils'; import { glob } from 'glob'; +import { ManifestEditorProvider } from './manifest-editor/manifest-editor-provider'; const WINAPP_DEBUG_TYPE = 'winapp'; @@ -384,6 +385,34 @@ export function activate(context: vscode.ExtensionContext) { vscode.debug.registerDebugAdapterDescriptorFactory(WINAPP_DEBUG_TYPE, factory) ); + // Register the AppxManifest visual editor + context.subscriptions.push(ManifestEditorProvider.register(context)); + + // When an appxmanifest file is opened in the default text editor, + // suggest switching to the visual editor. + const MANIFEST_PATTERN = /(?:^|[\\/])appxmanifest\.xml$|\.appxmanifest$/i; + const dismissedKey = 'winapp.manifestEditorNotificationDismissed'; + + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(editor => { + if (!editor || editor.document.uri.scheme !== 'file') { return; } + if (!MANIFEST_PATTERN.test(editor.document.uri.fsPath)) { return; } + if (context.globalState.get(dismissedKey)) { return; } + + vscode.window.showInformationMessage( + 'This file can be opened with the WinApp visual manifest editor for a richer editing experience.', + 'Open with AppxManifest Editor', + "Don't Show Again", + ).then(choice => { + if (choice === 'Open with AppxManifest Editor') { + vscode.commands.executeCommand('vscode.openWith', editor.document.uri, ManifestEditorProvider.viewType); + } else if (choice === "Don't Show Again") { + context.globalState.update(dismissedKey, true); + } + }); + }) + ); + // Register winapp.init command context.subscriptions.push( vscode.commands.registerCommand('winapp.init', async () => { diff --git a/src/winapp-VSC/src/manifest-editor/manifest-editor-provider.ts b/src/winapp-VSC/src/manifest-editor/manifest-editor-provider.ts new file mode 100644 index 00000000..f33da086 --- /dev/null +++ b/src/winapp-VSC/src/manifest-editor/manifest-editor-provider.ts @@ -0,0 +1,486 @@ +/** + * Custom Text Editor Provider for appxmanifest.xml files. + * Opens a webview-based form editor when an appxmanifest.xml is opened. + */ + +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as crypto from 'crypto'; +import { execFile } from 'child_process'; +import { parseManifest, applyFieldChange, addCapability, removeCapability, addPackageDependency, removePackageDependency, addTargetDeviceFamily, removeTargetDeviceFamily, moveTargetDeviceFamily, movePackageDependency, addMainPackageDependency, removeMainPackageDependency, moveMainPackageDependency, addDriverConstraint, removeDriverConstraint, moveDriverConstraint, addOSPackageDependency, removeOSPackageDependency, moveOSPackageDependency, addHostRuntimeDependency, removeHostRuntimeDependency, moveHostRuntimeDependency, addExternalDependency, removeExternalDependency, moveExternalDependency, addApplication, removeApplication, addExtension, removeExtension, updateExtensionField, addResource, removeResource, moveResource, setShowNameOnTiles, addPhoneIdentity, removePhoneIdentity, removeVisualAsset } from './manifest-parser'; +import { validateManifest } from './manifest-validator'; +import { getWebviewContent, getParseErrorContent } from './webview-content'; +import { WebviewToExtensionMessage } from './manifest-types'; +import { getWinappCliPath, WINAPP_CLI_CALLER_VALUE } from '../winapp-cli-utils'; + +export class ManifestEditorProvider implements vscode.CustomTextEditorProvider { + public static readonly viewType = 'winapp.manifestEditor'; + + constructor(private readonly context: vscode.ExtensionContext) {} + + public static register(context: vscode.ExtensionContext): vscode.Disposable { + const provider = new ManifestEditorProvider(context); + return vscode.window.registerCustomEditorProvider( + ManifestEditorProvider.viewType, + provider, + { + webviewOptions: { retainContextWhenHidden: true }, + supportsMultipleEditorsPerDocument: false, + }, + ); + } + + public async resolveCustomTextEditor( + document: vscode.TextDocument, + webviewPanel: vscode.WebviewPanel, + _token: vscode.CancellationToken, + ): Promise { + // When opened from Source Control diff or other non-file contexts, + // fall back to the default text editor so the user sees a proper diff. + if (document.uri.scheme !== 'file') { + webviewPanel.webview.html = ''; + await vscode.commands.executeCommand('vscode.openWith', document.uri, 'default'); + return; + } + + const manifestDir = vscode.Uri.file(path.dirname(document.uri.fsPath)); + const resourceRoots: vscode.Uri[] = [this.context.extensionUri, manifestDir]; + // Include workspace folder roots so relative paths with ".." can resolve + if (vscode.workspace.workspaceFolders) { + for (const wf of vscode.workspace.workspaceFolders) { + resourceRoots.push(wf.uri); + } + } + webviewPanel.webview.options = { + enableScripts: true, + localResourceRoots: resourceRoots, + }; + + const freshNonce = () => crypto.randomBytes(16).toString('hex'); + const manifestDirUri = webviewPanel.webview.asWebviewUri(manifestDir).toString(); + + // Track whether we're currently applying an edit to avoid feedback loops + let isApplyingEdit = false; + let showingErrorView = false; + + /** Try to parse — if it fails, show error view; if it succeeds, show/update editor. */ + const tryParseOrShowError = (text: string): boolean => { + try { + parseManifest(text); + return true; + } catch (e) { + const errMsg = e instanceof Error ? e.message : String(e); + if (!showingErrorView) { + showingErrorView = true; + webviewPanel.webview.html = getParseErrorContent(webviewPanel.webview, freshNonce(), errMsg); + } + return false; + } + }; + + /** Load the full editor view. */ + const showEditorView = () => { + showingErrorView = false; + webviewPanel.webview.html = getWebviewContent(webviewPanel.webview, freshNonce(), manifestDirUri); + // The editor will send 'ready' once loaded, which triggers updateWebview + }; + + // Table-driven dispatch for simple XML operations + const simpleDispatch: Record string> = { + addCapability: (t, m) => addCapability(t, m.capability), + removeCapability: (t, m) => removeCapability(t, m.capability), + addPhoneIdentity: (t) => addPhoneIdentity(t), + removePhoneIdentity: (t) => removePhoneIdentity(t), + addResource: (t, m) => addResource(t, m.resource), + removeResource: (t, m) => removeResource(t, m.index), + moveResource: (t, m) => moveResource(t, m.index, m.direction), + addPackageDependency: (t, m) => addPackageDependency(t, m.dependency), + removePackageDependency: (t, m) => removePackageDependency(t, m.index), + movePackageDependency: (t, m) => movePackageDependency(t, m.index, m.direction), + addTargetDeviceFamily: (t, m) => addTargetDeviceFamily(t, m.family), + removeTargetDeviceFamily: (t, m) => removeTargetDeviceFamily(t, m.index), + moveTargetDeviceFamily: (t, m) => moveTargetDeviceFamily(t, m.index, m.direction), + addMainPackageDependency: (t, m) => addMainPackageDependency(t, m.dependency), + removeMainPackageDependency: (t, m) => removeMainPackageDependency(t, m.index), + moveMainPackageDependency: (t, m) => moveMainPackageDependency(t, m.index, m.direction), + addDriverConstraint: (t, m) => addDriverConstraint(t, m.constraint), + removeDriverConstraint: (t, m) => removeDriverConstraint(t, m.index), + moveDriverConstraint: (t, m) => moveDriverConstraint(t, m.index, m.direction), + addOSPackageDependency: (t, m) => addOSPackageDependency(t, m.dependency), + removeOSPackageDependency: (t, m) => removeOSPackageDependency(t, m.index), + moveOSPackageDependency: (t, m) => moveOSPackageDependency(t, m.index, m.direction), + addHostRuntimeDependency: (t, m) => addHostRuntimeDependency(t, m.dependency), + removeHostRuntimeDependency: (t, m) => removeHostRuntimeDependency(t, m.index), + moveHostRuntimeDependency: (t, m) => moveHostRuntimeDependency(t, m.index, m.direction), + addExternalDependency: (t, m) => addExternalDependency(t, m.dependency), + removeExternalDependency: (t, m) => removeExternalDependency(t, m.index), + moveExternalDependency: (t, m) => moveExternalDependency(t, m.index, m.direction), + addApplication: (t) => addApplication(t), + removeApplication: (t, m) => removeApplication(t, m.index), + addExtension: (t, m) => addExtension(t, m.index, m.xml), + removeExtension: (t, m) => removeExtension(t, m.appIndex, m.extIndex), + updateExtensionField: (t, m) => updateExtensionField(t, m.appIndex, m.extIndex, m.fieldPath, m.value, m.isTextContent), + setShowNameOnTiles: (t, m) => setShowNameOnTiles(t, m.appIndex, m.tiles), + }; + + /** Browse for a file and return its path relative to the manifest directory. */ + async function browseAndApplyField(title: string, filters: Record): Promise { + const filePath = await vscode.window.showOpenDialog({ + canSelectFiles: true, canSelectFolders: false, canSelectMany: false, + title, filters, + defaultUri: vscode.Uri.file(path.dirname(document.uri.fsPath)), + }); + if (!filePath || filePath.length === 0) { return undefined; } + return path.relative(path.dirname(document.uri.fsPath), filePath[0].fsPath); + } + + /** Send the current document state to the webview. */ + const updateWebview = (forceAll = false) => { + const text = document.getText(); + let data; + try { + data = parseManifest(text); + } catch (e) { + const errMsg = e instanceof Error ? e.message : String(e); + if (!showingErrorView) { + showingErrorView = true; + webviewPanel.webview.html = getParseErrorContent(webviewPanel.webview, freshNonce(), errMsg); + } + return; + } + if (showingErrorView) { showEditorView(); } + const errors = validateManifest(data); + webviewPanel.webview.postMessage({ type: 'update', data, errors, forceAll }); + }; + + // Initial load: check if XML is valid + if (tryParseOrShowError(document.getText())) { + webviewPanel.webview.html = getWebviewContent(webviewPanel.webview, freshNonce(), manifestDirUri); + } + + // Listen for document changes (e.g., from the text editor, undo, or external edits) + const changeDocSub = vscode.workspace.onDidChangeTextDocument(e => { + if (e.document.uri.toString() === document.uri.toString() && !isApplyingEdit) { + if (showingErrorView) { + // Check if the XML is now valid — if so, switch to editor + const text = document.getText(); + if (tryParseOrShowError(text)) { + showEditorView(); + } + } else { + // External change (undo, redo, text editor) — force-update all fields + updateWebview(true); + } + } + }); + + // Flush pending webview input changes before save so Ctrl+S captures edits + // that are still in the 300ms debounce window. + let pendingSaveResolve: ((edits: vscode.TextEdit[]) => void) | null = null; + let pendingSaveNonce: string | null = null; + const willSaveSub = vscode.workspace.onWillSaveTextDocument(e => { + if (e.document.uri.toString() === document.uri.toString()) { + e.waitUntil(new Promise((resolve) => { + const nonce = crypto.randomUUID(); + pendingSaveResolve = resolve; + pendingSaveNonce = nonce; + webviewPanel.webview.postMessage({ type: 'flushChanges', nonce }); + // Timeout fallback — don't block save forever + setTimeout(() => { + if (pendingSaveNonce === nonce) { + pendingSaveResolve = null; + pendingSaveNonce = null; + resolve([]); + } + }, 500); + })); + } + }); + + webviewPanel.onDidDispose(() => { + changeDocSub.dispose(); + willSaveSub.dispose(); + }); + + // Handle messages from the webview + webviewPanel.webview.onDidReceiveMessage(async (message: WebviewToExtensionMessage) => { + const text = document.getText(); + let newText: string | undefined; + + try { + // Table-driven dispatch for simple XML operations + if (simpleDispatch[message.type]) { + newText = simpleDispatch[message.type](text, message); + } else { + switch (message.type) { + case 'ready': + updateWebview(); + return; + + case 'changesFlushed': { + // Apply all pending field changes and resolve the save promise + // Match nonce to prevent stale resolution from rapid double-saves + if (pendingSaveResolve && message.nonce === pendingSaveNonce) { + let result = text; + for (const change of message.changes) { + result = applyFieldChange(result, change.section, change.field, change.value, change.index); + } + const edits = result !== text + ? [vscode.TextEdit.replace(new vscode.Range(0, 0, document.lineCount, 0), result)] + : []; + const resolve = pendingSaveResolve; + pendingSaveResolve = null; + pendingSaveNonce = null; + resolve(edits); + } + return; + } + + case 'openAsText': + await vscode.commands.executeCommand('vscode.openWith', document.uri, 'default'); + return; + + case 'checkImagePath': { + const imgPath = message.imagePath; + const manifestDirPath = path.dirname(document.uri.fsPath); + const resolved = path.resolve(manifestDirPath, imgPath); + + // Check if the resolved path is inside the package directory AND exists + const normalizedResolved = resolved.toLowerCase(); + const normalizedManifestDir = manifestDirPath.toLowerCase() + path.sep; + if (normalizedResolved.startsWith(normalizedManifestDir) && fs.existsSync(resolved)) { + const dims = getImageDimensions(resolved); + const aspectWarning = dims ? checkAspectRatio(message.field, dims.width, dims.height) : null; + webviewPanel.webview.postMessage({ type: 'imagePathStatus', field: message.field, index: message.index, status: 'found', aspectWarning: aspectWarning || undefined }); + return; + } + + // The path resolves outside the package dir (e.g., ..\..\Downloads\img.png) + // or is an absolute path — check if the file exists at the resolved location + if (fs.existsSync(resolved)) { + webviewPanel.webview.postMessage({ type: 'imagePathStatus', field: message.field, index: message.index, status: 'external', sourcePath: resolved }); + return; + } + + // Check if it's an absolute path that exists + if (path.isAbsolute(imgPath) && fs.existsSync(imgPath)) { + webviewPanel.webview.postMessage({ type: 'imagePathStatus', field: message.field, index: message.index, status: 'external', sourcePath: imgPath }); + return; + } + + // Check relative to workspace folders + if (vscode.workspace.workspaceFolders) { + for (const wf of vscode.workspace.workspaceFolders) { + const candidate = path.resolve(wf.uri.fsPath, imgPath); + if (fs.existsSync(candidate) && !candidate.toLowerCase().startsWith(normalizedManifestDir)) { + webviewPanel.webview.postMessage({ type: 'imagePathStatus', field: message.field, index: message.index, status: 'external', sourcePath: candidate }); + return; + } + } + } + + webviewPanel.webview.postMessage({ type: 'imagePathStatus', field: message.field, index: message.index, status: 'notFound' }); + return; + } + + case 'copyToAssets': { + const manifestDirPath2 = path.dirname(document.uri.fsPath); + const assetsDir = path.join(manifestDirPath2, 'Assets'); + const sourcePath = message.sourcePath; + const fileName = path.basename(sourcePath); + + // Create Assets folder if it doesn't exist + if (!fs.existsSync(assetsDir)) { + fs.mkdirSync(assetsDir, { recursive: true }); + } + + // Handle name collision + let destName = fileName; + let destPath = path.join(assetsDir, destName); + if (fs.existsSync(destPath)) { + const ext = path.extname(fileName); + const base = path.basename(fileName, ext); + let counter = 1; + while (fs.existsSync(destPath)) { + destName = `${base}_${counter}${ext}`; + destPath = path.join(assetsDir, destName); + counter++; + } + } + + fs.copyFileSync(sourcePath, destPath); + const newRelPath = `Assets\\${destName}`; + + // Apply the field change with the new path + newText = applyFieldChange(text, message.section, message.field, newRelPath, message.index); + break; + } + + case 'fieldChanged': + newText = applyFieldChange(text, message.section, message.field, message.value, message.index, message.subIndex); + break; + + case 'removeVisualAsset': { + const veField = message.field.replace('visualElements.', ''); + newText = removeVisualAsset(text, message.index, veField); + break; + } + + case 'packageTypeChanged': { + // Set/clear the three mutually exclusive package type properties + let result = text; + result = applyFieldChange(result, 'properties', 'framework', message.value === 'framework' ? 'true' : ''); + result = applyFieldChange(result, 'properties', 'resourcePackage', message.value === 'resource' ? 'true' : ''); + result = applyFieldChange(result, 'properties', 'modificationPackage', message.value === 'modification' ? 'true' : ''); + newText = result; + break; + } + + case 'browseFile': { + const relPath = await browseAndApplyField('Select JSON file', { 'JSON': ['json'] }); + if (!relPath) { return; } + newText = updateExtensionField(text, message.appIndex, message.extIndex, message.fieldPath, relPath, true); + break; + } + + case 'browseImage': { + const relPath = await browseAndApplyField('Select image', { 'Images': ['png', 'jpg', 'jpeg', 'svg', 'ico'] }); + if (!relPath) { return; } + newText = applyFieldChange(text, message.section, message.field, relPath, message.index); + break; + } + + case 'browseExe': { + const relPath = await browseAndApplyField('Select executable', { 'Executables': ['exe'] }); + if (!relPath) { return; } + newText = applyFieldChange(text, message.section, message.field, relPath, message.index); + break; + } + + case 'updateAssets': { + const imagePath = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + title: 'Select source image for assets', + filters: { 'Images': ['png', 'jpg', 'jpeg', 'svg'] }, + }); + if (!imagePath || imagePath.length === 0) { return; } + + const cliPath = getWinappCliPath(this.context.extensionPath); + const cwd = path.dirname(document.uri.fsPath); + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Regenerating assets…', cancellable: false }, + () => new Promise((resolve, reject) => { + execFile(cliPath, ['manifest', 'update-assets', imagePath[0].fsPath], { cwd, env: { ...process.env, WINAPP_CLI_CALLER: WINAPP_CLI_CALLER_VALUE } }, (error) => { + if (error) { + vscode.window.showErrorMessage(`Asset regeneration failed: ${error.message}`); + reject(error); + } else { + resolve(); + } + }); + }), + ); + + webviewPanel.webview.postMessage({ type: 'refreshImages' }); + return; + } + } + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + console.warn('[ManifestEditor] XML manipulation failed:', err); + vscode.window.showErrorMessage(`Manifest edit failed: ${errMsg}`); + return; + } + + if (newText !== undefined && newText !== text) { + isApplyingEdit = true; + try { + const edit = new vscode.WorkspaceEdit(); + edit.replace( + document.uri, + new vscode.Range(0, 0, document.lineCount, 0), + newText, + ); + await vscode.workspace.applyEdit(edit); + } finally { + isApplyingEdit = false; + } + + // Update webview with the new state (including validation) + updateWebview(); + } + }); + } +} + +/** Reads width/height from PNG or JPEG file headers without loading the full image. */ +function getImageDimensions(filePath: string): { width: number; height: number } | null { + try { + const fd = fs.openSync(filePath, 'r'); + const header = Buffer.alloc(32); + fs.readSync(fd, header, 0, 32, 0); + + // PNG: bytes 0-7 are signature, IHDR chunk starts at byte 8, width at 16, height at 20 + if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4E && header[3] === 0x47) { + const width = header.readUInt32BE(16); + const height = header.readUInt32BE(20); + fs.closeSync(fd); + return { width, height }; + } + + // JPEG: scan for SOF0/SOF2 marker (0xFF 0xC0 or 0xFF 0xC2) + if (header[0] === 0xFF && header[1] === 0xD8) { + const buf = Buffer.alloc(65536); + fs.readSync(fd, buf, 0, buf.length, 0); + fs.closeSync(fd); + let offset = 2; + while (offset < buf.length - 9) { + if (buf[offset] !== 0xFF) break; + const marker = buf[offset + 1]; + if (marker === 0xC0 || marker === 0xC2) { + const height = buf.readUInt16BE(offset + 5); + const width = buf.readUInt16BE(offset + 7); + return { width, height }; + } + const len = buf.readUInt16BE(offset + 2); + offset += 2 + len; + } + return null; + } + + fs.closeSync(fd); + return null; + } catch { + return null; + } +} + +/** Expected aspect ratios for manifest image fields (width:height). */ +const EXPECTED_RATIOS: Record = { + 'visualElements.square150x150Logo': { w: 1, h: 1, label: '1:1 (square)' }, + 'visualElements.square44x44Logo': { w: 1, h: 1, label: '1:1 (square)' }, + 'visualElements.square71x71Logo': { w: 1, h: 1, label: '1:1 (square)' }, + 'visualElements.square310x310Logo': { w: 1, h: 1, label: '1:1 (square)' }, + 'visualElements.wide310x150Logo': { w: 310, h: 150, label: '310:150 (wide)' }, + 'visualElements.badgeLogo': { w: 1, h: 1, label: '1:1 (square)' }, + 'visualElements.splashScreenImage': { w: 620, h: 300, label: '620:300 (wide)' }, + 'logo': { w: 1, h: 1, label: '1:1 (square)' }, +}; + +/** Returns a warning string if the image aspect ratio doesn't match expectations (±5% tolerance). */ +function checkAspectRatio(field: string, width: number, height: number): string | null { + const expected = EXPECTED_RATIOS[field]; + if (!expected || width === 0 || height === 0) { return null; } + const actualRatio = width / height; + const expectedRatio = expected.w / expected.h; + const tolerance = 0.05; + if (Math.abs(actualRatio - expectedRatio) / expectedRatio > tolerance) { + return `Image is ${width}×${height} — expected ${expected.label} aspect ratio`; + } + return null; +} diff --git a/src/winapp-VSC/src/manifest-editor/manifest-parser.ts b/src/winapp-VSC/src/manifest-editor/manifest-parser.ts new file mode 100644 index 00000000..71883921 --- /dev/null +++ b/src/winapp-VSC/src/manifest-editor/manifest-parser.ts @@ -0,0 +1,955 @@ +/** + * Parse and modify appxmanifest.xml using @xmldom/xmldom. + * Reads XML into ManifestData for the form, and applies edits back to the XML text. + */ + +export * from './xml-utils'; +export * from './manifest-xml-ops'; + +import { DOMParser, XMLSerializer } from '@xmldom/xmldom'; +import type { Element } from '@xmldom/xmldom'; +import { + ManifestData, + IdentityData, + PhoneIdentityData, + PropertiesData, + DependenciesData, + TargetDeviceFamilyData, + PackageDependencyData, + MainPackageDependencyData, + DriverConstraintData, + OSPackageDependencyData, + HostRuntimeDependencyData, + ExternalDependencyData, + ApplicationData, + VisualElementsData, + ResourceData, +} from './manifest-types'; + +import { + NS, + escapeRegex, + escapeXmlAttr, + escapeXmlText, + replaceAttribute, + removeAttribute, + addAttributeToElement, + replaceElementText, + findParentBounds, + findDirectChildElementBounds, + ensureNamespace, + findNthApplicationRegion, + detectIndent, + buildVisualChildElement, +} from './xml-utils'; + +import { insertChildBeforeClose } from './manifest-xml-ops'; + +/** + * Parse appxmanifest.xml text into a ManifestData object. + * + * NOTE: Package-level (outside ) are not yet + * parsed or editable. They are preserved in the XML but not surfaced in the + * editor UI. Common package-level extensions include + * windows.activatableClass.inProcessServer and background task host DLLs. + * See: https://github.com/microsoft/winappCli/issues + */ +export function parseManifest(xmlText: string): ManifestData { + const doc = new DOMParser().parseFromString(xmlText, 'application/xml'); + const root = doc.documentElement; + if (!root) { throw new Error('Invalid XML: no root element'); } + + return { + identity: parseIdentity(root), + phoneIdentity: parsePhoneIdentity(root), + properties: parseProperties(root), + dependencies: parseDependencies(root), + applications: parseApplications(root), + capabilities: parseCapabilities(root), + resources: parseResources(root), + }; +} + +/** + * Apply a field change to the XML text and return the updated XML string. + * Uses surgical string replacements to preserve original formatting. + * Falls back to DOM parse/serialize only when a new element must be created. + */ +export function applyFieldChange( + xmlText: string, + section: string, + field: string, + value: string, + index?: number, + subIndex?: number, +): string { + const idx = index ?? 0; + + switch (section) { + case 'identity': + return applyIdentityChangeString(xmlText, field, value); + case 'phoneIdentity': + return applyPhoneIdentityChangeString(xmlText, field, value); + case 'properties': + return applyPropertiesChangeString(xmlText, field, value); + case 'dependencies': + return applyDependenciesChangeString(xmlText, field, value, idx, subIndex); + case 'applications': + return applyApplicationChangeString(xmlText, field, value, idx); + case 'resources': + return applyResourcesChangeString(xmlText, field, value, idx); + default: + return xmlText; + } +} + +// ─── Internal parsing helpers ─────────────────────────────────────── + +function parseIdentity(root: Element): IdentityData { + const el = getChildByLocalName(root, 'Identity'); + return { + name: el?.getAttribute('Name') ?? '', + publisher: el?.getAttribute('Publisher') ?? '', + version: el?.getAttribute('Version') ?? '', + processorArchitecture: el?.getAttribute('ProcessorArchitecture') ?? 'neutral', + resourceId: el?.getAttribute('ResourceId') ?? '', + }; +} + +function parsePhoneIdentity(root: Element): PhoneIdentityData | null { + const el = findChildByLocalNameNS(root, 'PhoneIdentity'); + if (!el) { return null; } + return { + phoneProductId: el.getAttribute('PhoneProductId') ?? '', + phonePublisherId: el.hasAttribute('PhonePublisherId') ? (el.getAttribute('PhonePublisherId') ?? '') : undefined, + }; +} + +function parseProperties(root: Element): PropertiesData { + const el = getChildByLocalName(root, 'Properties'); + + // Parse uap13:AutoUpdate → AppInstaller Uri + let autoUpdateUri = ''; + if (el) { + const autoUpdateEl = findChildByLocalNameNS(el, 'AutoUpdate'); + if (autoUpdateEl) { + const appInstallerEl = findChildByLocalNameNS(autoUpdateEl, 'AppInstaller'); + if (appInstallerEl) { + autoUpdateUri = appInstallerEl.getAttribute('Uri') ?? ''; + } + } + } + + // Parse uap10:PackageIntegrity → Content Enforcement + let packageIntegrityEnforcement = ''; + if (el) { + const pkgIntEl = findChildByLocalNameNS(el, 'PackageIntegrity'); + if (pkgIntEl) { + const contentEl = findChildByLocalNameNS(pkgIntEl, 'Content'); + if (contentEl) { + packageIntegrityEnforcement = contentEl.getAttribute('Enforcement') ?? ''; + } else { + // PackageIntegrity exists but no Content child — mark as present but not enforced + packageIntegrityEnforcement = 'false'; + } + } + } + + return { + displayName: getChildTextContent(el, 'DisplayName'), + publisherDisplayName: getChildTextContent(el, 'PublisherDisplayName'), + description: getChildTextContent(el, 'Description'), + logo: getChildTextContent(el, 'Logo'), + framework: getChildTextContent(el, 'Framework').toLowerCase(), + resourcePackage: getChildTextContent(el, 'ResourcePackage').toLowerCase(), + supportedUsers: getChildTextContent(el, 'SupportedUsers'), + allowExecution: getChildTextContent(el, 'AllowExecution'), + fileSystemWriteVirtualization: getChildTextContent(el, 'FileSystemWriteVirtualization'), + registryWriteVirtualization: getChildTextContent(el, 'RegistryWriteVirtualization'), + modificationPackage: getChildTextContent(el, 'ModificationPackage').toLowerCase(), + allowExternalContent: getChildTextContent(el, 'AllowExternalContent'), + autoUpdateUri, + packageIntegrityEnforcement, + updateWhileInUse: getChildTextContent(el, 'UpdateWhileInUse'), + }; +} + +function parseDependencies(root: Element): DependenciesData { + const el = getChildByLocalName(root, 'Dependencies'); + const targetDeviceFamilies: TargetDeviceFamilyData[] = []; + const packageDependencies: PackageDependencyData[] = []; + const mainPackageDependencies: MainPackageDependencyData[] = []; + const driverConstraints: DriverConstraintData[] = []; + const osPackageDependencies: OSPackageDependencyData[] = []; + const hostRuntimeDependencies: HostRuntimeDependencyData[] = []; + const externalDependencies: ExternalDependencyData[] = []; + + if (el) { + for (const child of getChildrenByLocalName(el, 'TargetDeviceFamily')) { + targetDeviceFamilies.push({ + name: child.getAttribute('Name') ?? '', + minVersion: child.getAttribute('MinVersion') ?? '', + maxVersionTested: child.getAttribute('MaxVersionTested') ?? '', + }); + } + for (const child of getChildrenByLocalName(el, 'PackageDependency')) { + packageDependencies.push({ + name: child.getAttribute('Name') ?? '', + minVersion: child.getAttribute('MinVersion') ?? '', + publisher: child.getAttribute('Publisher') ?? '', + optional: child.getAttribute('uap6:Optional') ?? '', + }); + } + for (const child of getChildrenByLocalName(el, 'MainPackageDependency')) { + mainPackageDependencies.push({ + name: child.getAttribute('Name') ?? '', + }); + } + for (const child of getChildrenByLocalName(el, 'DriverDependency')) { + for (const dc of getChildrenByLocalName(child, 'DriverConstraint')) { + driverConstraints.push({ + name: dc.getAttribute('Name') ?? '', + minVersion: dc.getAttribute('MinVersion') ?? '', + minDate: dc.getAttribute('MinDate') ?? '', + }); + } + } + for (const child of getChildrenByLocalName(el, 'OSPackageDependency')) { + osPackageDependencies.push({ + name: child.getAttribute('Name') ?? '', + version: child.getAttribute('Version') ?? '', + }); + } + for (const child of getChildrenByLocalName(el, 'HostRuntimeDependency')) { + hostRuntimeDependencies.push({ + name: child.getAttribute('Name') ?? '', + publisher: child.getAttribute('Publisher') ?? '', + minVersion: child.getAttribute('MinVersion') ?? '', + }); + } + for (const child of getChildrenByLocalName(el, 'ExternalDependency')) { + externalDependencies.push({ + name: child.getAttribute('Name') ?? '', + publisher: child.getAttribute('Publisher') ?? '', + minVersion: child.getAttribute('MinVersion') ?? '', + optional: child.getAttribute('Optional') ?? '', + }); + } + } + + return { + targetDeviceFamilies, packageDependencies, + mainPackageDependencies, driverConstraints, osPackageDependencies, + hostRuntimeDependencies, externalDependencies, + }; +} + +function parseApplications(root: Element): ApplicationData[] { + const appsEl = getChildByLocalName(root, 'Applications'); + if (!appsEl) { return []; } + + const apps: ApplicationData[] = []; + for (const appEl of getChildrenByLocalName(appsEl, 'Application')) { + const visualEl = findChildByLocalNameNS(appEl, 'VisualElements'); + const defaultTile = visualEl ? findChildByLocalNameNS(visualEl, 'DefaultTile') : null; + const lockScreen = visualEl ? findChildByLocalNameNS(visualEl, 'LockScreen') : null; + const splashScreen = visualEl ? findChildByLocalNameNS(visualEl, 'SplashScreen') : null; + + // Parse ShowNameOnTiles + const showNameOnTiles: string[] = []; + if (defaultTile) { + const showNameEl = findChildByLocalNameNS(defaultTile, 'ShowNameOnTiles'); + if (showNameEl) { + const showOnEls = getChildrenByLocalName(showNameEl, 'ShowOn'); + for (const showOn of showOnEls) { + const tile = showOn.getAttribute('Tile'); + if (tile) { showNameOnTiles.push(tile); } + } + } + } + + // Gather extension raw XML for display and editing + const extensions: string[] = []; + const extEl = getChildByLocalName(appEl, 'Extensions'); + if (extEl) { + const serializer = new XMLSerializer(); + const extChildren = extEl.childNodes; + for (let i = 0; i < extChildren.length; i++) { + const child = extChildren[i]; + if (child.nodeType === 1) { + extensions.push(serializer.serializeToString(child as Element)); + } + } + } + + apps.push({ + id: appEl.getAttribute('Id') ?? '', + executable: appEl.getAttribute('Executable') ?? '', + entryPoint: appEl.getAttribute('EntryPoint') ?? '', + trustLevel: appEl.getAttribute('uap10:TrustLevel') ?? appEl.getAttribute('TrustLevel') ?? '', + runtimeBehavior: appEl.getAttribute('uap10:RuntimeBehavior') ?? appEl.getAttribute('RuntimeBehavior') ?? '', + supportsMultipleInstances: appEl.getAttribute('uap10:SupportsMultipleInstances') ?? appEl.getAttribute('desktop4:SupportsMultipleInstances') ?? '', + parameters: appEl.getAttribute('uap10:Parameters') ?? '', + visualElements: { + displayName: visualEl?.getAttribute('DisplayName') ?? '', + description: visualEl?.getAttribute('Description') ?? '', + backgroundColor: visualEl?.getAttribute('BackgroundColor') ?? '', + square150x150Logo: visualEl?.getAttribute('Square150x150Logo') ?? '', + square44x44Logo: visualEl?.getAttribute('Square44x44Logo') ?? '', + appListEntry: visualEl?.getAttribute('AppListEntry') ?? '', + wide310x150Logo: defaultTile?.getAttribute('Wide310x150Logo') ?? null, + square71x71Logo: defaultTile?.getAttribute('Square71x71Logo') ?? null, + square310x310Logo: defaultTile?.getAttribute('Square310x310Logo') ?? null, + shortName: defaultTile?.getAttribute('ShortName') ?? '', + badgeLogo: lockScreen?.getAttribute('BadgeLogo') ?? null, + lockScreenNotification: lockScreen?.getAttribute('Notification') ?? '', + splashScreenImage: splashScreen?.getAttribute('Image') ?? null, + splashScreenBackgroundColor: splashScreen?.getAttribute('BackgroundColor') ?? '', + showNameOnTiles, + }, + extensions, + }); + } + return apps; +} + +function parseCapabilities(root: Element): string[] { + const capsEl = getChildByLocalName(root, 'Capabilities'); + if (!capsEl) { return []; } + + const capabilities: string[] = []; + const children = capsEl.childNodes; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.nodeType !== 1) { continue; } + const el = child as Element; + const name = el.getAttribute('Name') ?? ''; + if (!name) { continue; } + + const localName = el.localName ?? ''; + const prefix = el.prefix ?? ''; + + if (localName === 'DeviceCapability') { + capabilities.push(`device:${name}`); + } else if (localName === 'CustomCapability') { + // uap4:CustomCapability — store as just the Name (no prefix) + capabilities.push(name); + } else if (prefix) { + capabilities.push(`${prefix}:${name}`); + } else { + capabilities.push(name); + } + } + return capabilities; +} + +function parseResources(root: Element): ResourceData[] { + const resourcesEl = getChildByLocalName(root, 'Resources'); + if (!resourcesEl) { return []; } + + const resources: ResourceData[] = []; + for (const child of getChildrenByLocalName(resourcesEl, 'Resource')) { + resources.push({ + language: child.getAttribute('Language') ?? '', + scale: child.getAttribute('uap:Scale') ?? child.getAttribute('Scale') ?? '', + dxFeatureLevel: child.getAttribute('uap:DXFeatureLevel') ?? child.getAttribute('DXFeatureLevel') ?? '', + }); + } + return resources; +} + +// ─── Surgical string-based field change helpers ───────────────────── + + + +function applyIdentityChangeString(xml: string, field: string, value: string): string { + const attrMap: Record = { + name: 'Name', + publisher: 'Publisher', + version: 'Version', + processorArchitecture: 'ProcessorArchitecture', + resourceId: 'ResourceId', + }; + const attr = attrMap[field]; + if (!attr) { return xml; } + + const pattern = /]*>/s; + // For optional fields, empty value means remove the attribute + if (!value && field === 'resourceId') { + return removeAttribute(xml, pattern, attr); + } + const result = replaceAttribute(xml, pattern, attr, value); + if (result !== null) { return result; } + + // Attribute doesn't exist yet — add it + return addAttributeToElement(xml, pattern, attr, value); +} + +function applyPhoneIdentityChangeString(xml: string, field: string, value: string): string { + const attrMap: Record = { + phoneProductId: 'PhoneProductId', + phonePublisherId: 'PhonePublisherId', + }; + const attr = attrMap[field]; + if (!attr) { return xml; } + + const pattern = /<[a-zA-Z0-9]*:?PhoneIdentity\b[^>]*>/s; + + // For optional PhonePublisherId, empty value means remove the attribute + if (!value && field === 'phonePublisherId') { + return removeAttribute(xml, pattern, attr); + } + + const result = replaceAttribute(xml, pattern, attr, value); + if (result !== null) { return result; } + + // Attribute doesn't exist yet — add it + return addAttributeToElement(xml, pattern, attr, value); +} + +function applyPropertiesChangeString(xml: string, field: string, value: string): string { + const tagMap: Record = { + displayName: 'DisplayName', + publisherDisplayName: 'PublisherDisplayName', + description: 'Description', + logo: 'Logo', + framework: 'Framework', + resourcePackage: 'ResourcePackage', + supportedUsers: 'SupportedUsers', + allowExecution: 'AllowExecution', + fileSystemWriteVirtualization: 'FileSystemWriteVirtualization', + registryWriteVirtualization: 'RegistryWriteVirtualization', + modificationPackage: 'ModificationPackage', + allowExternalContent: 'AllowExternalContent', + updateWhileInUse: 'UpdateWhileInUse', + }; + + // Map of fields that need namespace prefixes when inserting new elements + const nsPrefix: Record = { + supportedUsers: { prefix: 'uap', uri: NS.uap }, + allowExecution: { prefix: 'uap6', uri: 'http://schemas.microsoft.com/appx/manifest/uap/windows10/6' }, + fileSystemWriteVirtualization: { prefix: 'desktop6', uri: 'http://schemas.microsoft.com/appx/manifest/desktop/windows10/6' }, + registryWriteVirtualization: { prefix: 'desktop6', uri: 'http://schemas.microsoft.com/appx/manifest/desktop/windows10/6' }, + modificationPackage: { prefix: 'rescap6', uri: 'http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities/6' }, + allowExternalContent: { prefix: 'uap10', uri: NS.uap10 }, + updateWhileInUse: { prefix: 'uap17', uri: 'http://schemas.microsoft.com/appx/manifest/uap/windows10/17' }, + }; + + // Special handling for autoUpdateUri (nested: uap13:AutoUpdate > uap13:AppInstaller Uri="...") + if (field === 'autoUpdateUri') { + return applyAutoUpdateUri(xml, value); + } + + // Special handling for packageIntegrityEnforcement (nested: uap10:PackageIntegrity > uap10:Content Enforcement="...") + if (field === 'packageIntegrityEnforcement') { + return applyPackageIntegrityEnforcement(xml, value); + } + + const tag = tagMap[field]; + if (!tag) { return xml; } + + // Match text (with any namespace prefix) + const tagRegex = new RegExp(`(<${tag}>|<[a-zA-Z0-9]+:${tag}>)(.*?)(<\\/${tag}>|<\\/[a-zA-Z0-9]+:${tag}>)`, 's'); + + // If value is empty, remove the element entirely + if (!value) { + const fullTagRegex = new RegExp(`[ \t]*(?:<${tag}>|<[a-zA-Z0-9]+:${tag}>).*?(?:<\\/${tag}>|<\\/[a-zA-Z0-9]+:${tag}>)[ \t]*\\r?\\n?`, 's'); + const removeMatch = fullTagRegex.exec(xml); + if (removeMatch) { + return xml.substring(0, removeMatch.index) + xml.substring(removeMatch.index + removeMatch[0].length); + } + return xml; + } + + const result = replaceElementText(xml, tagRegex, value); + + // If the element wasn't found and the value is non-empty, insert it into + if (result === xml && value) { + let workXml = xml; + + // Ensure namespace for prefixed elements + const ns = nsPrefix[field]; + if (ns) { + workXml = ensureNamespace(workXml, ns.prefix, ns.uri); + } + + let propsBounds = findParentBounds(workXml, 'Properties'); + if (!propsBounds) { + // Create before + const pkgClose = workXml.lastIndexOf(''); + if (pkgClose < 0) { return xml; } + const pkgIndent = detectIndent(workXml, pkgClose); + const propsIndent = pkgIndent + ' '; + const block = propsIndent + '\n' + propsIndent + '\n'; + let lineStart = pkgClose; + while (lineStart > 0 && workXml[lineStart - 1] !== '\n') { lineStart--; } + workXml = workXml.substring(0, lineStart) + block + workXml.substring(lineStart); + propsBounds = findParentBounds(workXml, 'Properties'); + if (!propsBounds) { return xml; } + } + const propIndent = detectIndent(workXml, propsBounds.openStart); + const elemTag = ns ? `${ns.prefix}:${tag}` : tag; + return insertChildBeforeClose(workXml, propsBounds.contentEnd, `<${elemTag}>${escapeXmlText(value)}`, propIndent); + } + + return result; +} + +/** Handle autoUpdateUri: manages uap13:AutoUpdate > uap13:AppInstaller Uri="..." */ +function applyAutoUpdateUri(xml: string, value: string): string { + const autoUpdateRegex = /[ \t]*<[a-zA-Z0-9]*:?AutoUpdate\b[^>]*>[\s\S]*?<\/[a-zA-Z0-9]*:?AutoUpdate\s*>[ \t]*\r?\n?/s; + if (!value) { + // Remove entire AutoUpdate block + const match = autoUpdateRegex.exec(xml); + if (match) { + return xml.substring(0, match.index) + xml.substring(match.index + match[0].length); + } + return xml; + } + + // Try to update existing AppInstaller Uri attribute (scoped to AutoUpdate block) + const autoUpdateBounds = findParentBounds(xml, 'AutoUpdate'); + if (autoUpdateBounds) { + const appInstallerRegex = /<[a-zA-Z0-9]*:?AppInstaller\b[^>]*\/?>/s; + const scopedXml = xml.substring(autoUpdateBounds.contentStart, autoUpdateBounds.contentEnd); + const appInstallerMatch = appInstallerRegex.exec(scopedXml); + if (appInstallerMatch) { + const absStart = autoUpdateBounds.contentStart + appInstallerMatch.index; + const absEnd = absStart + appInstallerMatch[0].length; + const fullRegex = new RegExp(escapeRegex(xml.substring(absStart, absEnd))); + const result = replaceAttribute(xml, fullRegex, 'Uri', value); + if (result !== null) { return result; } + } + } + + // No AutoUpdate element — insert one into Properties (guard against duplicate) + if (/AutoUpdate/i.test(xml)) { return xml; } + let workXml = ensureNamespace(xml, 'uap13', 'http://schemas.microsoft.com/appx/manifest/uap/windows/10/13'); + const propsBounds = findParentBounds(workXml, 'Properties'); + if (!propsBounds) { return xml; } + const propIndent = detectIndent(workXml, propsBounds.openStart); + const childIndent = propIndent + ' '; + const block = `\n${childIndent} \n${childIndent}`; + return insertChildBeforeClose(workXml, propsBounds.contentEnd, block, propIndent); +} + +/** Handle packageIntegrityEnforcement: manages uap10:PackageIntegrity > uap10:Content Enforcement="..." */ +function applyPackageIntegrityEnforcement(xml: string, value: string): string { + const pkgIntRegex = /[ \t]*<[a-zA-Z0-9]*:?PackageIntegrity\b[^>]*>[\s\S]*?<\/[a-zA-Z0-9]*:?PackageIntegrity\s*>[ \t]*\r?\n?/s; + if (!value) { + // Remove entire PackageIntegrity block + const match = pkgIntRegex.exec(xml); + if (match) { + return xml.substring(0, match.index) + xml.substring(match.index + match[0].length); + } + return xml; + } + + // Try to update existing Content Enforcement attribute (scoped to PackageIntegrity block) + const pkgIntBounds = findParentBounds(xml, 'PackageIntegrity'); + if (pkgIntBounds) { + const contentRegex = /<[a-zA-Z0-9]*:?Content\b[^>]*\/?>/s; + const scopedXml = xml.substring(pkgIntBounds.contentStart, pkgIntBounds.contentEnd); + const contentMatch = contentRegex.exec(scopedXml); + if (contentMatch) { + const absStart = pkgIntBounds.contentStart + contentMatch.index; + const absEnd = absStart + contentMatch[0].length; + const fullRegex = new RegExp(escapeRegex(xml.substring(absStart, absEnd))); + const result = replaceAttribute(xml, fullRegex, 'Enforcement', value); + if (result !== null) { return result; } + } + } + + // No PackageIntegrity element — insert one into Properties (guard against duplicate) + if (/PackageIntegrity/i.test(xml)) { return xml; } + let workXml = ensureNamespace(xml, 'uap10', NS.uap10); + const propsBounds = findParentBounds(workXml, 'Properties'); + if (!propsBounds) { return xml; } + const propIndent = detectIndent(workXml, propsBounds.openStart); + const childIndent = propIndent + ' '; + const block = `\n${childIndent} \n${childIndent}`; + return insertChildBeforeClose(workXml, propsBounds.contentEnd, block, propIndent); +} + +/** Find the Nth element matching tagRegex, then replace or add the given attribute. */ +function applyNthElementAttrChange( + xml: string, tagRegex: RegExp, index: number, attr: string, value: string, + opts?: { removeOnEmpty?: boolean } +): string { + let match: RegExpExecArray | null; + let count = 0; + while ((match = tagRegex.exec(xml)) !== null) { + if (count === index) { + // Use positional slicing to ensure we operate on this specific match, + // not the first textually-identical element elsewhere in the XML. + const pos = match.index; + const elemText = match[0]; + const before = xml.substring(0, pos); + const after = xml.substring(pos + elemText.length); + const elemRegex = new RegExp(escapeRegex(elemText)); + if (opts?.removeOnEmpty && !value) { + const modified = removeAttribute(elemText, elemRegex, attr); + return before + modified + after; + } + const replaced = replaceAttribute(elemText, elemRegex, attr, value); + if (replaced !== null) { return before + replaced + after; } + const added = addAttributeToElement(elemText, elemRegex, attr, value); + return before + added + after; + } + count++; + } + return xml; +} + +const DEP_FIELD_CONFIG: Record; + removeOnEmpty?: string[]; + ensureNamespace?: { prefix: string; uri: string; fields: string[] }; +}> = { + targetDeviceFamily: { + prefix: 'targetDeviceFamily.', + tagRegex: /]*\/?>/gs, + attrMap: { name: 'Name', minVersion: 'MinVersion', maxVersionTested: 'MaxVersionTested' }, + }, + packageDependency: { + prefix: 'packageDependency.', + tagRegex: /]*\/?>/gs, + attrMap: { name: 'Name', minVersion: 'MinVersion', publisher: 'Publisher', optional: 'uap6:Optional' }, + removeOnEmpty: ['optional'], + ensureNamespace: { prefix: 'uap6', uri: 'http://schemas.microsoft.com/appx/manifest/uap/windows10/6', fields: ['optional'] }, + }, + mainPackageDependency: { + prefix: 'mainPackageDependency.', + tagRegex: /]*\/?>/gs, + attrMap: { name: 'Name' }, + }, + osPackageDependency: { + prefix: 'osPackageDependency.', + tagRegex: /]*\/?>/gs, + attrMap: { name: 'Name', version: 'Version' }, + }, + hostRuntimeDependency: { + prefix: 'hostRuntimeDependency.', + tagRegex: /]*\/?>/gs, + attrMap: { name: 'Name', publisher: 'Publisher', minVersion: 'MinVersion' }, + }, + externalDependency: { + prefix: 'externalDependency.', + tagRegex: /]*\/?>/gs, + attrMap: { name: 'Name', publisher: 'Publisher', minVersion: 'MinVersion', optional: 'Optional' }, + removeOnEmpty: ['optional'], + }, + driverConstraint: { + prefix: 'driverConstraint.', + tagRegex: /]*\/?>/gs, + attrMap: { name: 'Name', minVersion: 'MinVersion', minDate: 'MinDate' }, + }, +}; + +function applyDependenciesChangeString(xml: string, field: string, value: string, index: number, subIndex?: number): string { + for (const cfg of Object.values(DEP_FIELD_CONFIG)) { + if (field.startsWith(cfg.prefix)) { + const subField = field.replace(cfg.prefix, ''); + const attr = cfg.attrMap[subField]; + if (!attr) { return xml; } + // Reset lastIndex since regexes have the global flag + cfg.tagRegex.lastIndex = 0; + const removeOnEmpty = cfg.removeOnEmpty?.includes(subField); + // Ensure namespace if configured for this field + if (cfg.ensureNamespace && cfg.ensureNamespace.fields.includes(subField) && value) { + xml = ensureNamespace(xml, cfg.ensureNamespace.prefix, cfg.ensureNamespace.uri); // lgtm[js/incomplete-multi-character-sanitization] + } + return applyNthElementAttrChange(xml, cfg.tagRegex, index, attr, value, + removeOnEmpty ? { removeOnEmpty: true } : undefined); + } + } + + return xml; +} + +function applyResourcesChangeString(xml: string, field: string, value: string, index: number): string { + const attrMap: Record = { + language: 'Language', + scale: 'uap:Scale', + dxFeatureLevel: 'uap:DXFeatureLevel', + }; + let attr = attrMap[field]; + if (!attr) { return xml; } + + const regex = /]*\/?>/gs; + let match: RegExpExecArray | null; + let count = 0; + while ((match = regex.exec(xml)) !== null) { + if (count === index) { + // M4: Detect if the element uses unprefixed variant (Scale vs uap:Scale) + if (attr.startsWith('uap:')) { + const bareAttr = attr.substring(4); + if (match[0].includes(`${bareAttr}=`) && !match[0].includes(`uap:${bareAttr}=`)) { + attr = bareAttr; + } + } + + const elemRegex = new RegExp(escapeRegex(match[0])); + if (!value) { + return removeAttribute(xml, elemRegex, attr); + } + + // Ensure uap namespace when adding uap-prefixed attrs + let workXml = xml; + if (attr.startsWith('uap:')) { + workXml = ensureNamespace(workXml, 'uap', NS.uap); + // Re-find element after possible namespace insertion shift + const reMatch = new RegExp(escapeRegex(match[0])).exec(workXml); + if (!reMatch) { return workXml; } + } + + const workElemRegex = new RegExp(escapeRegex(match[0])); + const result = replaceAttribute(workXml, workElemRegex, attr, value); + if (result !== null) { return result; } + return addAttributeToElement(workXml, workElemRegex, attr, value); + } + count++; + } + return xml; +} + +function applyApplicationChangeString(xml: string, field: string, value: string, index: number): string { + // Top-level Application attributes + const appAttrMap: Record = { + id: 'Id', + executable: 'Executable', + entryPoint: 'EntryPoint', + }; + // Optional Application attributes that should be removed when empty + const optionalAppAttrs: Record = { + trustLevel: 'uap10:TrustLevel', + runtimeBehavior: 'uap10:RuntimeBehavior', + supportsMultipleInstances: 'uap10:SupportsMultipleInstances', + parameters: 'uap10:Parameters', + }; + if (appAttrMap[field] || optionalAppAttrs[field]) { + const attr = appAttrMap[field] || optionalAppAttrs[field]; + const regex = /]*>/gs; + let match: RegExpExecArray | null; + let count = 0; + while ((match = regex.exec(xml)) !== null) { + if (count === index) { + const elemRegex = new RegExp(escapeRegex(match[0])); + // Optional attrs: remove when empty + if (optionalAppAttrs[field] && !value) { + return removeAttribute(xml, elemRegex, attr); + } + // Ensure uap10 namespace for uap10: attributes + if (optionalAppAttrs[field]?.startsWith('uap10:')) { + xml = ensureNamespace(xml, 'uap10', 'http://schemas.microsoft.com/appx/manifest/uap/windows10/10'); + // Re-find after namespace insertion + const regex2 = /]*>/gs; + let m2: RegExpExecArray | null; + let c2 = 0; + while ((m2 = regex2.exec(xml)) !== null) { + if (c2 === index) { + const elemRegex2 = new RegExp(escapeRegex(m2[0])); + const result = replaceAttribute(xml, elemRegex2, attr, value); + if (result !== null) { return result; } + return addAttributeToElement(xml, elemRegex2, attr, value); + } + c2++; + } + return xml; + } + const result = replaceAttribute(xml, elemRegex, attr, value); + if (result !== null) { return result; } + return addAttributeToElement(xml, elemRegex, attr, value); + } + count++; + } + return xml; + } + + // VisualElements attributes — scope searches to the nth Application's region + if (field.startsWith('visualElements.')) { + const veField = field.replace('visualElements.', ''); + + // Find the bounds of the nth Application element to scope all searches + const appRegion = findNthApplicationRegion(xml, index); + if (!appRegion) { return xml; } + const { start: appStart, end: appEnd } = appRegion; + const appXml = xml.substring(appStart, appEnd); + + function applyScopedAttrOp( + fullXml: string, pattern: RegExp, attrName: string, + op: 'replace' | 'add' | 'remove', newValue?: string + ): string | null { + const region = fullXml.substring(appStart, appEnd); + const match = pattern.exec(region); + if (!match) { return null; } + const absIdx = appStart + match.index; + const elemRegex = new RegExp(escapeRegex(match[0])); + const before = fullXml.substring(0, absIdx); + const after = fullXml.substring(absIdx); + if (op === 'remove') { + const removed = removeAttribute(after, elemRegex, attrName); + return removed !== after ? before + removed : fullXml; + } else if (op === 'replace') { + const replaced = replaceAttribute(after, elemRegex, attrName, newValue!); + return replaced !== null ? before + replaced : null; + } else { + const added = addAttributeToElement(after, elemRegex, attrName, newValue!); + return before + added; + } + } + + // Attributes on DefaultTile + const defaultTileAttrs: Record = { + wide310x150Logo: 'Wide310x150Logo', + square71x71Logo: 'Square71x71Logo', + square310x310Logo: 'Square310x310Logo', + shortName: 'ShortName', + }; + if (defaultTileAttrs[veField]) { + const dtPattern = /<[a-zA-Z0-9]*:?DefaultTile\b[^>]*?\/?>/s; + if (!value && veField === 'shortName') { + return applyScopedAttrOp(xml, dtPattern, defaultTileAttrs[veField], 'remove') ?? xml; + } + const result = applyScopedAttrOp(xml, /<[a-zA-Z0-9]*:?DefaultTile\b[^>]*>/s, defaultTileAttrs[veField], 'replace', value); + if (result !== null) { return result; } + const addResult = applyScopedAttrOp(xml, dtPattern, defaultTileAttrs[veField], 'add', value); + if (addResult !== null) { return addResult; } + // No DefaultTile element exists — fall through to create one + } + + // Attributes on LockScreen + if (veField === 'badgeLogo' || veField === 'lockScreenNotification') { + const lockAttr = veField === 'badgeLogo' ? 'BadgeLogo' : 'Notification'; + const lsPattern = /<[a-zA-Z0-9]*:?LockScreen\b[^>]*?\/?>/s; + if (!value && veField === 'lockScreenNotification') { + return applyScopedAttrOp(xml, lsPattern, lockAttr, 'remove') ?? xml; + } + const result = applyScopedAttrOp(xml, /<[a-zA-Z0-9]*:?LockScreen\b[^>]*>/s, lockAttr, 'replace', value); + if (result !== null) { return result; } + const addResult = applyScopedAttrOp(xml, lsPattern, lockAttr, 'add', value); + if (addResult !== null) { return addResult; } + // No LockScreen element exists — fall through to create one + } + + // Attributes on SplashScreen + if (veField === 'splashScreenImage' || veField === 'splashScreenBackgroundColor') { + const splashAttr = veField === 'splashScreenImage' ? 'Image' : 'BackgroundColor'; + const ssPattern = /<[a-zA-Z0-9]*:?SplashScreen\b[^>]*?\/?>/s; + if (!value && veField === 'splashScreenBackgroundColor') { + return applyScopedAttrOp(xml, ssPattern, splashAttr, 'remove') ?? xml; + } + const result = applyScopedAttrOp(xml, /<[a-zA-Z0-9]*:?SplashScreen\b[^>]*>/s, splashAttr, 'replace', value); + if (result !== null) { return result; } + const addResult = applyScopedAttrOp(xml, ssPattern, splashAttr, 'add', value); + if (addResult !== null) { return addResult; } + // No SplashScreen element exists — fall through to create one + } + + // AppListEntry on VisualElements + const attrMap: Record = { + displayName: 'DisplayName', + description: 'Description', + backgroundColor: 'BackgroundColor', + square150x150Logo: 'Square150x150Logo', + square44x44Logo: 'Square44x44Logo', + appListEntry: 'AppListEntry', + }; + if (attrMap[veField]) { + if (!value && veField === 'appListEntry') { + return applyScopedAttrOp(xml, /<[a-zA-Z0-9]*:?VisualElements\b[^>]*?\/?>/s, attrMap[veField], 'remove') ?? xml; + } + return applyScopedAttrOp(xml, /<[a-zA-Z0-9]*:?VisualElements\b[^>]*>/s, attrMap[veField], 'replace', value) ?? xml; + } + + // Fallback: surgically insert new child element inside VisualElements + const veClosePattern = /(<[a-zA-Z0-9]*:?VisualElements\b[^>]*?)\s*\/>/s; + const veCloseMatch = veClosePattern.exec(appXml); + if (veCloseMatch) { + // Self-closing VisualElements — convert to open/close and insert child + const absPos = appStart + veCloseMatch.index; + const indent = detectIndent(xml, absPos); + const childIndent = indent + ' '; + const childXml = buildVisualChildElement(veField, value); + if (childXml) { + return xml.substring(0, absPos) + + veCloseMatch[1] + '>\n' + + childIndent + childXml + '\n' + + indent + '' + + xml.substring(absPos + veCloseMatch[0].length); + } + } else { + // Non-self-closing VisualElements — insert before closing tag + const veEndPattern = /<\/[a-zA-Z0-9]*:?VisualElements\s*>/s; + const veEndMatch = veEndPattern.exec(appXml); + if (veEndMatch) { + const absEndPos = appStart + veEndMatch.index; + // Try to detect child indent from an existing child element (e.g., DefaultTile) + const existingChildPattern = /\n([ \t]+)<[a-zA-Z0-9]*:?(?:DefaultTile|LockScreen|SplashScreen)\b/; + const existingChildMatch = existingChildPattern.exec(appXml); + const veEndIndent = detectIndent(xml, absEndPos); + const childIndent = existingChildMatch ? existingChildMatch[1] : (veEndIndent + ' '); + const childXml = buildVisualChildElement(veField, value); + if (childXml) { + // Find the start of the whitespace preceding the closing tag + const beforeClose = xml.substring(0, absEndPos); + const trailingWsMatch = /\n[ \t]*$/.exec(beforeClose); + const insertPos = trailingWsMatch ? absEndPos - trailingWsMatch[0].length : absEndPos; + return xml.substring(0, insertPos) + + '\n' + childIndent + childXml + + '\n' + veEndIndent + veEndMatch[0] + + xml.substring(absEndPos + veEndMatch[0].length); + } + } + } + + return xml; + } + + return xml; +} + +// ─── DOM utility helpers ──────────────────────────────────────────── + +function getChildByLocalName(parent: Element | null, localName: string): Element | null { + if (!parent) { return null; } + const children = parent.childNodes; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.nodeType === 1 && (child as Element).localName === localName) { + return child as Element; + } + } + return null; +} + +function getChildrenByLocalName(parent: Element, localName: string): Element[] { + const result: Element[] = []; + const children = parent.childNodes; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.nodeType === 1 && (child as Element).localName === localName) { + result.push(child as Element); + } + } + return result; +} + +/** Find a child element by local name, checking across all namespaces (for uap:VisualElements, etc.). */ +function findChildByLocalNameNS(parent: Element, localName: string): Element | null { + const children = parent.childNodes; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.nodeType === 1 && (child as Element).localName === localName) { + return child as Element; + } + } + return null; +} + +function getChildTextContent(parent: Element | null, localName: string): string { + const child = getChildByLocalName(parent, localName); + return child?.textContent ?? ''; +} diff --git a/src/winapp-VSC/src/manifest-editor/manifest-types.ts b/src/winapp-VSC/src/manifest-editor/manifest-types.ts new file mode 100644 index 00000000..0df74c71 --- /dev/null +++ b/src/winapp-VSC/src/manifest-editor/manifest-types.ts @@ -0,0 +1,401 @@ +/** + * Type definitions for the AppxManifest visual editor. + */ + +/** Data extracted from an appxmanifest.xml for the form editor. */ +export interface ManifestData { + identity: IdentityData; + phoneIdentity: PhoneIdentityData | null; + properties: PropertiesData; + dependencies: DependenciesData; + applications: ApplicationData[]; + capabilities: string[]; + resources: ResourceData[]; +} + +export interface IdentityData { + name: string; + publisher: string; + version: string; + processorArchitecture: string; + resourceId: string; +} + +export interface PhoneIdentityData { + phoneProductId: string; + phonePublisherId: string | undefined; +} + +export interface PropertiesData { + displayName: string; + publisherDisplayName: string; + description: string; + logo: string; + framework: string; + resourcePackage: string; + supportedUsers: string; + allowExecution: string; + fileSystemWriteVirtualization: string; + registryWriteVirtualization: string; + modificationPackage: string; + allowExternalContent: string; + autoUpdateUri: string; + packageIntegrityEnforcement: string; + updateWhileInUse: string; +} + +export interface DependenciesData { + targetDeviceFamilies: TargetDeviceFamilyData[]; + packageDependencies: PackageDependencyData[]; + mainPackageDependencies: MainPackageDependencyData[]; + driverConstraints: DriverConstraintData[]; + osPackageDependencies: OSPackageDependencyData[]; + hostRuntimeDependencies: HostRuntimeDependencyData[]; + externalDependencies: ExternalDependencyData[]; +} + +export interface TargetDeviceFamilyData { + name: string; + minVersion: string; + maxVersionTested: string; +} + +export interface PackageDependencyData { + name: string; + minVersion: string; + publisher: string; + optional: string; +} + +export interface MainPackageDependencyData { + name: string; +} + +export interface DriverConstraintData { + name: string; + minVersion: string; + minDate: string; +} + +export interface OSPackageDependencyData { + name: string; + version: string; +} + +export interface HostRuntimeDependencyData { + name: string; + publisher: string; + minVersion: string; +} + +export interface ExternalDependencyData { + name: string; + publisher: string; + minVersion: string; + optional: string; +} + +export interface ApplicationData { + id: string; + executable: string; + entryPoint: string; + trustLevel: string; + runtimeBehavior: string; + supportsMultipleInstances: string; + parameters: string; + visualElements: VisualElementsData; + extensions: string[]; +} + +export interface VisualElementsData { + displayName: string; + description: string; + backgroundColor: string; + square150x150Logo: string; + square44x44Logo: string; + appListEntry: string; + wide310x150Logo: string | null; + square71x71Logo: string | null; + square310x310Logo: string | null; + badgeLogo: string | null; + splashScreenImage: string | null; + splashScreenBackgroundColor: string; + lockScreenNotification: string; + shortName: string; + showNameOnTiles: string[]; +} + +export interface ResourceData { + language: string; + scale: string; + dxFeatureLevel: string; +} + +export const RESOURCE_SCALE_OPTIONS = ['', '80', '100', '120', '125', '140', '150', '160', '175', '180', '200', '225', '250', '300', '350', '400', '450'] as const; +export const RESOURCE_DX_OPTIONS = ['', 'dx9', 'dx10', 'dx11', 'dx12'] as const; + +/** Validation error for a single field. */ +export interface ValidationError { + field: string; + message: string; + severity: 'error' | 'warning'; +} + +/** Message types sent from the extension to the webview. */ +export type ExtensionToWebviewMessage = + | { type: 'update'; data: ManifestData; errors: ValidationError[] } + | { type: 'validationErrors'; errors: ValidationError[] } + | { type: 'refreshImages' } + | { type: 'flushChanges' }; + +/** Message types sent from the webview to the extension. */ +export type WebviewToExtensionMessage = + | { type: 'fieldChanged'; section: string; field: string; value: string; index?: number; subIndex?: number } + | { type: 'setShowNameOnTiles'; appIndex: number; tiles: string[] } + | { type: 'addResource'; resource: ResourceData } + | { type: 'removeResource'; index: number } + | { type: 'moveResource'; index: number; direction: 'up' | 'down' } + | { type: 'addCapability';capability: string } + | { type: 'removeCapability'; capability: string } + | { type: 'addPackageDependency'; dependency: PackageDependencyData } + | { type: 'removePackageDependency'; index: number } + | { type: 'addTargetDeviceFamily'; family: TargetDeviceFamilyData } + | { type: 'removeTargetDeviceFamily'; index: number } + | { type: 'moveTargetDeviceFamily'; index: number; direction: 'up' | 'down' } + | { type: 'addApplication' } + | { type: 'removeApplication'; index: number } + | { type: 'addExtension'; index: number; xml: string } + | { type: 'removeExtension'; appIndex: number; extIndex: number } + | { type: 'updateExtensionField'; appIndex: number; extIndex: number; fieldPath: string; value: string; isTextContent?: boolean } + | { type: 'browseFile'; appIndex: number; extIndex: number; fieldPath: string } + | { type: 'browseImage'; section: string; field: string; index?: number } + | { type: 'removeVisualAsset'; field: string; index: number } + | { type: 'checkImagePath'; imagePath: string; field: string; index?: number } + | { type: 'copyToAssets'; sourcePath: string; section: string; field: string; index?: number } + | { type: 'browseExe'; section: string; field: string; index?: number } + | { type: 'movePackageDependency'; index: number; direction: 'up' | 'down' } + | { type: 'addMainPackageDependency'; dependency: MainPackageDependencyData } + | { type: 'removeMainPackageDependency'; index: number } + | { type: 'moveMainPackageDependency'; index: number; direction: 'up' | 'down' } + | { type: 'addDriverConstraint'; constraint: DriverConstraintData } + | { type: 'removeDriverConstraint'; index: number } + | { type: 'moveDriverConstraint'; index: number; direction: 'up' | 'down' } + | { type: 'addOSPackageDependency'; dependency: OSPackageDependencyData } + | { type: 'removeOSPackageDependency'; index: number } + | { type: 'moveOSPackageDependency'; index: number; direction: 'up' | 'down' } + | { type: 'addHostRuntimeDependency'; dependency: HostRuntimeDependencyData } + | { type: 'removeHostRuntimeDependency'; index: number } + | { type: 'moveHostRuntimeDependency'; index: number; direction: 'up' | 'down' } + | { type: 'addExternalDependency'; dependency: ExternalDependencyData } + | { type: 'removeExternalDependency'; index: number } + | { type: 'moveExternalDependency'; index: number; direction: 'up' | 'down' } + | { type: 'updateAssets' } + | { type: 'openAsText' } + | { type: 'addPhoneIdentity' } + | { type: 'removePhoneIdentity' } + | { type: 'packageTypeChanged'; value: string } + | { type: 'ready' } + | { type: 'changesFlushed'; changes: Array<{ section: string; field: string; value: string; index: number }>; nonce?: string }; + +/** Known capabilities organized by category for the checklist UI. */ +export const KNOWN_CAPABILITIES = { + general: [ + { name: 'internetClient', label: 'Internet (Client)', namespace: '' }, + { name: 'internetClientServer', label: 'Internet (Client & Server)', namespace: '' }, + { name: 'privateNetworkClientServer', label: 'Private Networks (Client & Server)', namespace: '' }, + { name: 'codeGeneration', label: 'Code Generation', namespace: '' }, + { name: 'musicLibrary', label: 'Music Library', namespace: 'uap' }, + { name: 'picturesLibrary', label: 'Pictures Library', namespace: 'uap' }, + { name: 'videosLibrary', label: 'Videos Library', namespace: 'uap' }, + { name: 'removableStorage', label: 'Removable Storage', namespace: 'uap' }, + { name: 'appointments', label: 'Appointments', namespace: 'uap' }, + { name: 'contacts', label: 'Contacts', namespace: 'uap' }, + { name: 'enterpriseAuthentication', label: 'Enterprise Authentication', namespace: '' }, + { name: 'sharedUserCertificates', label: 'Shared User Certificates', namespace: '' }, + { name: 'phoneCall', label: 'Phone Call', namespace: 'uap' }, + { name: 'userAccountInformation', label: 'User Account Information', namespace: 'uap' }, + { name: 'voipCall', label: 'VoIP Call', namespace: 'uap' }, + { name: 'objects3D', label: '3D Objects', namespace: 'uap' }, + { name: 'chat', label: 'Chat', namespace: 'uap' }, + { name: 'blockedChatMessages', label: 'Blocked Chat Messages', namespace: 'uap' }, + { name: 'backgroundMediaPlayback', label: 'Background Media Playback', namespace: 'uap3' }, + { name: 'remoteSystem', label: 'Remote System', namespace: 'uap4' }, + { name: 'spatialPerception', label: 'Spatial Perception', namespace: 'uap2' }, + { name: 'globalMediaControl', label: 'Global Media Control', namespace: 'uap7' }, + { name: 'graphicsCapture', label: 'Graphics Capture', namespace: 'uap6' }, + { name: 'userDataTasks', label: 'User Data Tasks', namespace: 'uap4' }, + { name: 'userNotificationListener', label: 'User Notification Listener', namespace: 'uap3' }, + ], + restricted: [ + { name: 'runFullTrust', label: 'Run Full Trust', namespace: 'rescap' }, + { name: 'allowElevation', label: 'Allow Elevation', namespace: 'rescap' }, + { name: 'unvirtualizedResources', label: 'Unvirtualized Resources', namespace: 'rescap' }, + { name: 'packagedShellExtension', label: 'Packaged Shell Extension', namespace: 'rescap' }, + { name: 'appDiagnostics', label: 'App Diagnostics', namespace: 'rescap' }, + { name: 'broadFileSystemAccess', label: 'Broad File System Access', namespace: 'rescap' }, + { name: 'packageManagement', label: 'Package Management', namespace: 'rescap' }, + { name: 'packageQuery', label: 'Package Query', namespace: 'rescap' }, + { name: 'localSystemServices', label: 'Local System Services', namespace: 'rescap' }, + { name: 'inputForegroundObservation', label: 'Input Foreground Observation', namespace: 'rescap' }, + { name: 'confirmAppClose', label: 'Confirm App Close', namespace: 'rescap' }, + ], + device: [ + { name: 'microphone', label: 'Microphone', namespace: 'device' }, + { name: 'webcam', label: 'Webcam', namespace: 'device' }, + { name: 'location', label: 'Location', namespace: 'device' }, + { name: 'bluetooth', label: 'Bluetooth', namespace: 'device' }, + { name: 'proximity', label: 'Proximity', namespace: 'device' }, + { name: 'usb', label: 'USB', namespace: 'device' }, + { name: 'humaninterfacedevice', label: 'Human Interface Device (HID)', namespace: 'device' }, + { name: 'pointOfService', label: 'Point of Service', namespace: 'device' }, + { name: 'wiFiControl', label: 'Wi-Fi Control', namespace: 'device' }, + { name: 'radios', label: 'Radios', namespace: 'device' }, + { name: 'optical', label: 'Optical', namespace: 'device' }, + { name: 'activity', label: 'Activity', namespace: 'device' }, + { name: 'serialcommunication', label: 'Serial Communication', namespace: 'device' }, + { name: 'gazeInput', label: 'Gaze Input', namespace: 'device' }, + { name: 'lowLevelDevices', label: 'Low Level Devices', namespace: 'device' }, + { name: 'lowLevel', label: 'Low Level', namespace: 'device' }, + { name: 'systemAIModels', label: 'System AI Models', namespace: 'systemai' }, + ], +} as const; + +/** Extension templates for the Add Extension menu. */ +export const EXTENSION_TEMPLATES = [ + { + label: 'MCP Server', + category: 'windows.appExtension', + xml: '\n \n \n \n \n \n', + }, + { + label: 'COM Server', + category: 'windows.comServer', + xml: '\n \n \n \n \n \n', + }, + { + label: 'App Execution Alias', + category: 'windows.appExecutionAlias', + xml: '\n \n \n \n', + }, + { + label: 'Background Tasks', + category: 'windows.backgroundTasks', + xml: '\n \n \n \n', + }, + { + label: 'Protocol Activation', + category: 'windows.protocol', + xml: '\n \n', + }, + { + label: 'File Type Association', + category: 'windows.fileTypeAssociation', + xml: '\n \n \n \n .example\n \n \n', + }, + { + label: 'Startup Task', + category: 'windows.startupTask', + xml: '\n \n', + }, + { + label: 'Share Target', + category: 'windows.shareTarget', + xml: '\n \n \n \n \n Text\n \n', + }, + { + label: 'App Service', + category: 'windows.appService', + xml: '\n \n', + }, + { + label: 'Toast Notification Activation', + category: 'windows.toastNotificationActivation', + xml: '\n \n', + }, +] as const; + +/** Descriptions for known capabilities. */ +export const CAPABILITY_DESCRIPTIONS: Record = { + internetClient: 'Provides outbound access to the internet and networks in public places like airports and coffee shops.', + internetClientServer: 'Provides inbound and outbound access to the internet and networks in public places.', + privateNetworkClientServer: 'Provides inbound and outbound access to home and work networks through the firewall.', + codeGeneration: 'Allows the app to generate code dynamically using JIT compilation.', + musicLibrary: 'Provides access to the user\'s music library.', + picturesLibrary: 'Provides access to the user\'s pictures library.', + videosLibrary: 'Provides access to the user\'s videos library.', + removableStorage: 'Provides access to files on removable storage (USB drives, external hard drives).', + appointments: 'Provides access to the user\'s appointment store.', + contacts: 'Provides access to the user\'s contacts.', + enterpriseAuthentication: 'Allows the app to use Windows integrated authentication (Kerberos/NTLM).', + sharedUserCertificates: 'Provides access to software and hardware certificates (smart cards, etc.).', + phoneCall: 'Allows the app to access phone lines and place calls.', + userAccountInformation: 'Provides access to the user\'s name and picture.', + voipCall: 'Allows the app to access VoIP calling APIs.', + objects3D: 'Provides programmatic access to the user\'s 3D Objects folder.', + chat: 'Allows the app to read and delete text messages.', + blockedChatMessages: 'Allows the app to read chat messages blocked by the spam filter.', + backgroundMediaPlayback: 'Allows audio and video playback while the app is in the background.', + remoteSystem: 'Allows the app to discover and connect to remote devices.', + spatialPerception: 'Provides access to spatial mapping data for mixed-reality apps.', + globalMediaControl: 'Allows the app to access system media transport controls.', + graphicsCapture: 'Allows the app to capture screen, window, or display content.', + userDataTasks: 'Provides access to user data tasks (to-do items).', + userNotificationListener: 'Provides access to user notifications in the action center.', + runFullTrust: 'Allows a desktop app to run with full trust permissions outside the app container.', + allowElevation: 'Allows a packaged app to request elevated (admin) privileges at launch.', + unvirtualizedResources: 'Allows the app to access file system and registry locations without virtualization.', + packagedShellExtension: 'Allows the app to register shell extensions (context menu handlers, preview handlers, etc.).', + appDiagnostics: 'Allows the app to access diagnostic information about other running apps.', + broadFileSystemAccess: 'Provides broad access to the file system (beyond specific libraries).', + packageManagement: 'Allows the app to manage other packages (install, remove, etc.).', + packageQuery: 'Allows the app to query information about installed packages.', + localSystemServices: 'Allows the app to communicate with local system services.', + inputForegroundObservation: 'Allows the app to observe foreground input even when not in the foreground.', + confirmAppClose: 'Allows the app to intercept and confirm close operations.', + systemAIModels: 'Grants the app access to system-wide on-device AI models provided by Windows.', + microphone: 'Provides access to the microphone for audio capture.', + webcam: 'Provides access to the webcam for video capture.', + location: 'Provides access to the device location (GPS, Wi-Fi, etc.).', + bluetooth: 'Provides access to Bluetooth devices for communication.', + proximity: 'Provides access to Near Field Communication (NFC) devices.', + usb: 'Provides access to USB devices.', + humaninterfacedevice: 'Provides access to Human Interface Devices (HID).', + pointOfService: 'Provides access to point-of-service peripherals (barcode scanners, etc.).', + wiFiControl: 'Allows the app to scan for and connect to Wi-Fi networks.', + radios: 'Allows the app to toggle device radios (Wi-Fi, Bluetooth, etc.).', + optical: 'Provides access to optical disc drives.', + activity: 'Provides access to activity sensors (accelerometer, pedometer).', + serialcommunication: 'Provides access to serial communication ports.', + gazeInput: 'Provides access to eye-tracking/gaze input devices.', + lowLevelDevices: 'Provides low-level access to GPIO, I2C, SPI, and PWM devices.', + lowLevel: 'Provides access to low-level device resources.', +}; + +/** Processor architecture dropdown options. */ +export const ARCHITECTURE_OPTIONS = ['x86', 'x64', 'arm', 'arm64', 'x86a64', 'neutral'] as const; + +/** Target device family dropdown options. */ +export const DEVICE_FAMILY_OPTIONS = [ + 'Windows.Universal', + 'Windows.Desktop', + 'Windows.Mobile', + 'Windows.Xbox', + 'Windows.Holographic', + 'Windows.IoT', +] as const; + +/** Optional visual asset types that can be added to an application. */ +export const OPTIONAL_VISUAL_ASSETS = [ + { field: 'wide310x150Logo', label: 'Wide 310x150 Logo', placeholder: 'Assets\\Wide310x150Logo.png', description: 'Wide tile image for the Start menu — package-relative path or key in resources.pri' }, + { field: 'square71x71Logo', label: 'Square 71x71 Logo', placeholder: 'Assets\\Square71x71Logo.png', description: 'Small tile image — package-relative path or key in resources.pri' }, + { field: 'square310x310Logo', label: 'Square 310x310 Logo', placeholder: 'Assets\\Square310x310Logo.png', description: 'Large tile image for the Start menu — package-relative path or key in resources.pri' }, + { field: 'badgeLogo', label: 'Badge Logo', placeholder: 'Assets\\BadgeLogo.png', description: 'Badge notification image shown on the lock screen — package-relative path or key in resources.pri' }, + { field: 'splashScreenImage', label: 'Splash Screen', placeholder: 'Assets\\SplashScreen.png', description: 'Image displayed while the app is launching — package-relative path or key in resources.pri' }, +] as const; + +/** Tile sizes that support showing the app name overlay. */ +export const SHOW_NAME_ON_TILES_OPTIONS = [ + { tile: 'square150x150Logo', label: 'Medium (150×150)', veField: 'square150x150Logo' }, + { tile: 'wide310x150Logo', label: 'Wide (310×150)', veField: 'wide310x150Logo' }, + { tile: 'square310x310Logo', label: 'Large (310×310)', veField: 'square310x310Logo' }, +] as const; diff --git a/src/winapp-VSC/src/manifest-editor/manifest-validator.ts b/src/winapp-VSC/src/manifest-editor/manifest-validator.ts new file mode 100644 index 00000000..dcc820f4 --- /dev/null +++ b/src/winapp-VSC/src/manifest-editor/manifest-validator.ts @@ -0,0 +1,653 @@ +/** + * Validation rules for appxmanifest.xml fields. + * Provides real-time inline validation for the form editor. + */ + +import { ManifestData, ValidationError } from './manifest-types'; + +const VERSION_REGEX = /^\d+\.\d+\.\d+\.\d+$/; +// Full X.500 DN structural pattern matching the appxmanifest schema constraint (RFC 2253). +// Allowed RDN aliases: CN, L, O, OU, E, C, S, STREET, T, G, I, SN, DC, SERIALNUMBER, +// Description, PostalCode, POBox, Phone, X21Address, dnQualifier, or OID.x.y.z... +// Reserved characters (,+"<>=;\/\r\n) in unquoted values must be escaped with a backslash +// or encoded as hex (\XX). Values may also be quoted ("...") per RFC 2253. +// Note: # is only reserved at the start of a value; positional rules (leading space/#, +// trailing space) are enforced by isValidPublisherDN() below. +// ReDoS-safe: uses flat alternation ([^special\\]|\\.)+ to avoid nested quantifiers. +const PUBLISHER_DN_REGEX = /^(CN|L|O|OU|E|C|S|STREET|T|G|I|SN|DC|SERIALNUMBER|Description|PostalCode|POBox|Phone|X21Address|dnQualifier|(OID\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))+))=(([^,+="<>;\\/\r\n\\]|\\.)+|"([^"\\]|\\.)*")(,\s*((CN|L|O|OU|E|C|S|STREET|T|G|I|SN|DC|SERIALNUMBER|Description|PostalCode|POBox|Phone|X21Address|dnQualifier|(OID\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))+))=(([^,+="<>;\\/\r\n\\]|\\.)+|"([^"\\]|\\.)*")))*$/; +const IDENTITY_NAME_REGEX = /^[a-zA-Z0-9.\-]+$/; +const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; +const GUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; +// BCP-47: language[-script][-region][-variant] (simplified for common MSIX usage) +// Also accepts private-use tags like "x-generate" used by MSIX tooling +const BCP47_REGEX = /^(?:x(?:-[a-zA-Z0-9]{1,8})+|[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-[a-zA-Z]{2}|\d{3})?(-[a-zA-Z0-9]{5,8})*)$/; +// Application.Id: ASCII, alpha-numeric fields separated by periods, each field starts with a letter +const APP_ID_REGEX = /^[a-zA-Z][a-zA-Z0-9]*(\.[a-zA-Z][a-zA-Z0-9]*)*$/; +// uap4:CustomCapability Name: "company.capabilitynamefromstore_publisherId" +// Must have at least one dot-separated segment before the underscore, and a 13-char base32 publisher ID after. +const CUSTOM_CAPABILITY_REGEX = /^[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)+_[a-z0-9]{13}$/; + +/** + * Validate a publisher distinguished name per RFC 2253. + * Checks structural regex plus positional rules: + * - Unescaped space or # at the start of an RDN value is invalid + * - Unescaped space at the end of an RDN value is invalid + */ +function isValidPublisherDN(value: string): boolean { + if (!PUBLISHER_DN_REGEX.test(value)) { return false; } + // Walk the DN to check each RDN value for positional reserved characters + let i = 0; + while (i < value.length) { + const eqIdx = value.indexOf('=', i); + if (eqIdx < 0) { break; } + i = eqIdx + 1; + if (i < value.length && value[i] === '"') { + // Quoted value — skip to closing quote (no positional restrictions) + i = value.indexOf('"', i + 1); + if (i < 0) { break; } + i++; + } else { + // Unquoted value — check leading space or # + if (i < value.length && (value[i] === ' ' || value[i] === '#')) { + return false; + } + // Find end of this value (next unescaped comma or end of string) + let valueEnd = i; + while (valueEnd < value.length) { + if (value[valueEnd] === '\\' && valueEnd + 1 < value.length) { + valueEnd += 2; + } else if (value[valueEnd] === ',') { + break; + } else { + valueEnd++; + } + } + // Check trailing unescaped space + if (valueEnd > i && value[valueEnd - 1] === ' ') { + // Count preceding backslashes to determine if the space is escaped + let bs = 0; + let j = valueEnd - 2; + while (j >= i && value[j] === '\\') { bs++; j--; } + if (bs % 2 === 0) { return false; } // even backslashes → space is unescaped + } + i = valueEnd; + } + // Skip comma and optional separator whitespace between RDNs + if (i < value.length && value[i] === ',') { + i++; + while (i < value.length && value[i] === ' ') { i++; } + } + } + return true; +} + +/** Reserved device names that cannot be used as Identity Name, ResourceId, or Application Id fields. */ +const RESERVED_NAMES = new Set([ + 'CON', 'PRN', 'AUX', 'NUL', + 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', + 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9', +]); + +/** Named colors accepted by the appxmanifest schema for BackgroundColor. */ +const NAMED_COLORS = new Set([ + 'aliceBlue', 'antiqueWhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', + 'blanchedAlmond', 'blue', 'blueViolet', 'brown', 'burlyWood', 'cadetBlue', 'chartreuse', + 'chocolate', 'coral', 'cornflowerBlue', 'cornsilk', 'crimson', 'cyan', 'darkBlue', 'darkCyan', + 'darkGoldenrod', 'darkGray', 'darkGreen', 'darkKhaki', 'darkMagenta', 'darkOliveGreen', + 'darkOrange', 'darkOrchid', 'darkRed', 'darkSalmon', 'darkSeaGreen', 'darkSlateBlue', + 'darkSlateGray', 'darkTurquoise', 'darkViolet', 'deepPink', 'deepSkyBlue', 'dimGray', + 'dodgerBlue', 'firebrick', 'floralWhite', 'forestGreen', 'fuchsia', 'gainsboro', 'ghostWhite', + 'gold', 'goldenrod', 'gray', 'green', 'greenYellow', 'honeydew', 'hotPink', 'indianRed', + 'indigo', 'ivory', 'khaki', 'lavender', 'lavenderBlush', 'lawnGreen', 'lemonChiffon', + 'lightBlue', 'lightCoral', 'lightCyan', 'lightGoldenrodYellow', 'lightGray', 'lightGreen', + 'lightPink', 'lightSalmon', 'lightSeaGreen', 'lightSkyBlue', 'lightSlateGray', 'lightSteelBlue', + 'lightYellow', 'lime', 'limeGreen', 'linen', 'magenta', 'maroon', 'mediumAquamarine', + 'mediumBlue', 'mediumOrchid', 'mediumPurple', 'mediumSeaGreen', 'mediumSlateBlue', + 'mediumSpringGreen', 'mediumTurquoise', 'mediumVioletRed', 'midnightBlue', 'mintCream', + 'mistyRose', 'moccasin', 'navajoWhite', 'navy', 'oldLace', 'olive', 'oliveDrab', 'orange', + 'orangeRed', 'orchid', 'paleGoldenrod', 'paleGreen', 'paleTurquoise', 'paleVioletRed', + 'papayaWhip', 'peachPuff', 'peru', 'pink', 'plum', 'powderBlue', 'purple', 'red', 'rosyBrown', + 'royalBlue', 'saddleBrown', 'salmon', 'sandyBrown', 'seaGreen', 'seaShell', 'sienna', 'silver', + 'skyBlue', 'slateBlue', 'slateGray', 'snow', 'springGreen', 'steelBlue', 'tan', 'teal', + 'thistle', 'tomato', 'transparent', 'turquoise', 'violet', 'wheat', 'white', 'whiteSmoke', + 'yellow', 'yellowGreen', +]); + +/** Validate a DotQuadNumber: four dot-separated integers each 0–65535. */ +function isValidDotQuadNumber(value: string): boolean { + if (!VERSION_REGEX.test(value)) { return false; } + return value.split('.').every(part => { + const n = parseInt(part, 10); + return n >= 0 && n <= 65535; + }); +} + +/** + * Validate a uap4:CustomCapability Name attribute. + * Format: company.capabilitynamefromstore_publisherId + * - alphanumeric segments separated by dots (at least two segments before underscore) + * - followed by underscore and a 13-character base32 publisher ID (lowercase letters and digits) + */ +export function isValidCustomCapability(name: string): boolean { + return CUSTOM_CAPABILITY_REGEX.test(name); +} + +/** + * Returns true if a value is an MRT resource reference. + * MRT prefixed strings (ms-resource:) are explicit resource lookups. + * All path values are also run through MRT before falling back to the literal path, + * so even "foo.png" could be a key that resolves to a different file. + */ +function isMrtReference(value: string): boolean { + return value.startsWith('ms-resource:'); +} + +/** + * Returns true if a path has an unsupported image file extension. + * Only checks literal file paths — MRT resource keys are always valid. + * Extensionless values are valid (could be scale/contrast-qualified or MRT keys). + */ +function hasUnsupportedImageExtension(path: string): boolean { + if (isMrtReference(path)) { return false; } + const filename = path.split(/[\\/]/).pop() || ''; + const dotIdx = filename.lastIndexOf('.'); + if (dotIdx < 0) { return false; } // no extension — valid (MRT key or scale-qualified) + const ext = filename.substring(dotIdx).toLowerCase(); + // Allow known image extensions and MRT qualifier patterns (e.g. .scale-200, .contrast-high) + if (ext === '.png' || ext === '.jpg' || ext === '.jpeg') { return false; } + if (/^\.(scale|contrast|targetsize|theme|layoutdirection|language|dxfeaturelevel)-/i.test(ext)) { return false; } + return true; +} + +const IMAGE_FORMAT_ERROR = 'Visual assets should be .png, .jpg, or .jpeg files, or an MRT resource key (ms-resource:).'; + +/** Validate an image field: error if blank (but present in manifest), warn if unsupported extension. */ +function validateImageField(errors: ValidationError[], field: string, value: string | null | undefined): void { + if (value === '') { + errors.push({ field, message: 'Image path cannot be empty.', severity: 'error' }); + } else if (value && hasUnsupportedImageExtension(value)) { + errors.push({ field, message: IMAGE_FORMAT_ERROR, severity: 'warning' }); + } +} + +/** Validate all fields and return a list of errors. */ +export function validateManifest(data: ManifestData): ValidationError[] { + const errors: ValidationError[] = []; + validateIdentity(data, errors); + validatePhoneIdentity(data, errors); + validateProperties(data, errors); + validateDependencies(data, errors); + validateResources(data, errors); + validateApplications(data, errors); + return errors; +} + +function validateIdentity(data: ManifestData, errors: ValidationError[]): void { + if (!data.identity.name) { + errors.push({ field: 'identity.name', message: 'Package name is required.', severity: 'error' }); + } else if (!IDENTITY_NAME_REGEX.test(data.identity.name)) { + errors.push({ field: 'identity.name', message: 'Package name can only contain letters, numbers, dots, and hyphens.', severity: 'error' }); + } else if (data.identity.name.length < 3) { + errors.push({ field: 'identity.name', message: 'Package name must be at least 3 characters.', severity: 'error' }); + } else if (data.identity.name.length > 50) { + errors.push({ field: 'identity.name', message: 'Package name must be 50 characters or fewer.', severity: 'error' }); + } else if (RESERVED_NAMES.has(data.identity.name.toUpperCase())) { + errors.push({ field: 'identity.name', message: 'Package name cannot be a reserved device name (CON, PRN, AUX, NUL, COM1–9, LPT1–9).', severity: 'error' }); + } + + if (!data.identity.publisher) { + errors.push({ field: 'identity.publisher', message: 'Publisher is required.', severity: 'error' }); + } else if (!isValidPublisherDN(data.identity.publisher)) { + errors.push({ field: 'identity.publisher', message: 'Publisher must be a valid X.500 distinguished name (e.g. CN=Contoso, O=Contoso Ltd).', severity: 'error' }); + } + + if (!data.identity.version) { + errors.push({ field: 'identity.version', message: 'Version is required.', severity: 'error' }); + } else if (!isValidDotQuadNumber(data.identity.version)) { + errors.push({ field: 'identity.version', message: 'Version must be a DotQuadNumber in Major.Minor.Build.Revision format (e.g. 1.0.0.0), each part 0–65535.', severity: 'error' }); + } + + if (data.identity.resourceId) { + if (!IDENTITY_NAME_REGEX.test(data.identity.resourceId)) { + errors.push({ field: 'identity.resourceId', message: 'Resource ID can only contain letters, numbers, dots, and hyphens.', severity: 'error' }); + } else if (data.identity.resourceId.length > 30) { + errors.push({ field: 'identity.resourceId', message: 'Resource ID must be 30 characters or fewer.', severity: 'error' }); + } else if (RESERVED_NAMES.has(data.identity.resourceId.toUpperCase())) { + errors.push({ field: 'identity.resourceId', message: 'Resource ID cannot be a reserved device name (CON, PRN, AUX, NUL, COM1–9, LPT1–9).', severity: 'error' }); + } + } + + if (data.properties.resourcePackage === 'true' && + data.identity.processorArchitecture && + data.identity.processorArchitecture.toLowerCase() !== 'neutral') { + errors.push({ field: 'identity.processorArchitecture', message: 'Resource packages must use neutral processor architecture.', severity: 'error' }); + } +} + +function validatePhoneIdentity(data: ManifestData, errors: ValidationError[]): void { + if (!data.phoneIdentity) { return; } + if (!data.phoneIdentity.phoneProductId || !GUID_REGEX.test(data.phoneIdentity.phoneProductId)) { + errors.push({ field: 'phoneIdentity.phoneProductId', message: 'Phone Product ID must be a valid GUID (e.g. 00000000-0000-0000-0000-000000000000).', severity: 'error' }); + } + if (data.phoneIdentity.phonePublisherId && !GUID_REGEX.test(data.phoneIdentity.phonePublisherId)) { + errors.push({ field: 'phoneIdentity.phonePublisherId', message: 'Phone Publisher ID must be a valid GUID (e.g. 00000000-0000-0000-0000-000000000000).', severity: 'error' }); + } +} + +function validateProperties(data: ManifestData, errors: ValidationError[]): void { + if (!data.properties.displayName) { + errors.push({ field: 'properties.displayName', message: 'Display name is required.', severity: 'error' }); + } else if (data.properties.displayName.length > 256) { + errors.push({ field: 'properties.displayName', message: 'Display name must be 256 characters or fewer.', severity: 'error' }); + } + + if (!data.properties.publisherDisplayName) { + errors.push({ field: 'properties.publisherDisplayName', message: 'Publisher display name is required.', severity: 'error' }); + } else if (data.properties.publisherDisplayName.length > 256) { + errors.push({ field: 'properties.publisherDisplayName', message: 'Publisher display name must be 256 characters or fewer.', severity: 'error' }); + } + + if (!data.properties.logo) { + errors.push({ field: 'properties.logo', message: 'Store logo path is required.', severity: 'error' }); + } + validateImageField(errors, 'properties.logo', data.properties.logo); + + if (data.properties.description && data.properties.description.length > 2048) { + errors.push({ field: 'properties.description', message: 'Description must be 2048 characters or fewer.', severity: 'error' }); + } else if (data.properties.description && /[\t\r\n]/.test(data.properties.description)) { + errors.push({ field: 'properties.description', message: 'Description cannot contain tabs, carriage returns, or line feeds.', severity: 'error' }); + } +} + +function validateDependencies(data: ManifestData, errors: ValidationError[]): void { + for (let i = 0; i < data.dependencies.targetDeviceFamilies.length; i++) { + const family = data.dependencies.targetDeviceFamilies[i]; + const prefix = `dependencies.targetDeviceFamily.${i}`; + + if (!family.minVersion) { + errors.push({ field: `${prefix}.minVersion`, message: 'MinVersion is required.', severity: 'error' }); + } else if (!isValidDotQuadNumber(family.minVersion)) { + errors.push({ field: `${prefix}.minVersion`, message: 'MinVersion must be a DotQuadNumber (e.g. 10.0.17763.0), each part 0–65535.', severity: 'error' }); + } + + if (!family.maxVersionTested) { + errors.push({ field: `${prefix}.maxVersionTested`, message: 'MaxVersionTested is required.', severity: 'error' }); + } else if (!isValidDotQuadNumber(family.maxVersionTested)) { + errors.push({ field: `${prefix}.maxVersionTested`, message: 'MaxVersionTested must be a DotQuadNumber (e.g. 10.0.26100.0), each part 0–65535.', severity: 'error' }); + } + + if (family.minVersion && family.maxVersionTested && + isValidDotQuadNumber(family.minVersion) && isValidDotQuadNumber(family.maxVersionTested)) { + if (compareVersions(family.maxVersionTested, family.minVersion) < 0) { + errors.push({ field: `${prefix}.maxVersionTested`, message: 'MaxVersionTested must be greater than or equal to MinVersion.', severity: 'error' }); + } + } + } + + for (let i = 0; i < data.dependencies.packageDependencies.length; i++) { + const dep = data.dependencies.packageDependencies[i]; + const prefix = `dependencies.packageDependency.${i}`; + + if (!dep.name) { + errors.push({ field: `${prefix}.name`, message: 'Package dependency name is required.', severity: 'error' }); + } else if (!IDENTITY_NAME_REGEX.test(dep.name)) { + errors.push({ field: `${prefix}.name`, message: 'Name can only contain letters, numbers, dots, and hyphens.', severity: 'error' }); + } else if (dep.name.length < 3 || dep.name.length > 50) { + errors.push({ field: `${prefix}.name`, message: 'Name must be between 3 and 50 characters.', severity: 'error' }); + } + + if (!dep.minVersion) { + errors.push({ field: `${prefix}.minVersion`, message: 'MinVersion is required.', severity: 'error' }); + } else if (!isValidDotQuadNumber(dep.minVersion)) { + errors.push({ field: `${prefix}.minVersion`, message: 'MinVersion must be a 4-part dotted version (e.g. 14.0.0.0), each part 0–65535.', severity: 'error' }); + } + + if (!dep.publisher) { + errors.push({ field: `${prefix}.publisher`, message: 'Publisher is required.', severity: 'error' }); + } else if (!isValidPublisherDN(dep.publisher)) { + errors.push({ field: `${prefix}.publisher`, message: 'Publisher must be a valid X.500 distinguished name (e.g. CN=Microsoft Corporation, O=Microsoft Corporation).', severity: 'error' }); + } + } + + for (let i = 0; i < data.dependencies.mainPackageDependencies.length; i++) { + const dep = data.dependencies.mainPackageDependencies[i]; + const prefix = `dependencies.mainPackageDependency.${i}`; + + if (!dep.name) { + errors.push({ field: `${prefix}.name`, message: 'Main package dependency name is required.', severity: 'error' }); + } else if (!IDENTITY_NAME_REGEX.test(dep.name)) { + errors.push({ field: `${prefix}.name`, message: 'Name can only contain letters, numbers, dots, and hyphens.', severity: 'error' }); + } else if (dep.name.length < 3 || dep.name.length > 50) { + errors.push({ field: `${prefix}.name`, message: 'Name must be between 3 and 50 characters.', severity: 'error' }); + } + } + + for (let i = 0; i < data.dependencies.driverConstraints.length; i++) { + const constraint = data.dependencies.driverConstraints[i]; + const prefix = `dependencies.driverConstraint.${i}`; + + if (!constraint.name) { + errors.push({ field: `${prefix}.name`, message: 'Driver constraint name is required.', severity: 'error' }); + } + + if (!constraint.minVersion) { + errors.push({ field: `${prefix}.minVersion`, message: 'Driver constraint MinVersion is required.', severity: 'error' }); + } else if (!isValidDotQuadNumber(constraint.minVersion)) { + errors.push({ field: `${prefix}.minVersion`, message: 'MinVersion must be a DotQuadNumber (e.g. 1.0.0.0), each part 0–65535.', severity: 'error' }); + } + + if (!constraint.minDate) { + errors.push({ field: `${prefix}.minDate`, message: 'Driver constraint MinDate is required.', severity: 'error' }); + } else if (!/^\d{4}-\d{2}-\d{2}$/.test(constraint.minDate)) { + errors.push({ field: `${prefix}.minDate`, message: 'MinDate must be in YYYY-MM-DD format (e.g. 2020-01-01).', severity: 'error' }); + } + } + + for (let i = 0; i < data.dependencies.osPackageDependencies.length; i++) { + const dep = data.dependencies.osPackageDependencies[i]; + const prefix = `dependencies.osPackageDependency.${i}`; + + if (!dep.name) { + errors.push({ field: `${prefix}.name`, message: 'OS package dependency name is required.', severity: 'error' }); + } else if (!IDENTITY_NAME_REGEX.test(dep.name)) { + errors.push({ field: `${prefix}.name`, message: 'Name can only contain letters, numbers, dots, and hyphens.', severity: 'error' }); + } else if (dep.name.length < 3 || dep.name.length > 50) { + errors.push({ field: `${prefix}.name`, message: 'Name must be between 3 and 50 characters.', severity: 'error' }); + } + + if (!dep.version) { + errors.push({ field: `${prefix}.version`, message: 'OS package dependency version is required.', severity: 'error' }); + } else if (!isValidDotQuadNumber(dep.version)) { + errors.push({ field: `${prefix}.version`, message: 'Version must be a DotQuadNumber (e.g. 10.0.0.0), each part 0–65535.', severity: 'error' }); + } + } + + for (let i = 0; i < data.dependencies.hostRuntimeDependencies.length; i++) { + const dep = data.dependencies.hostRuntimeDependencies[i]; + const prefix = `dependencies.hostRuntimeDependency.${i}`; + + if (!dep.name) { + errors.push({ field: `${prefix}.name`, message: 'Host runtime dependency name is required.', severity: 'error' }); + } + + if (!dep.publisher) { + errors.push({ field: `${prefix}.publisher`, message: 'Host runtime dependency publisher is required.', severity: 'error' }); + } else if (!isValidPublisherDN(dep.publisher)) { + errors.push({ field: `${prefix}.publisher`, message: 'Publisher must be a valid X.500 distinguished name (e.g. CN=Contoso).', severity: 'error' }); + } + + if (!dep.minVersion) { + errors.push({ field: `${prefix}.minVersion`, message: 'Host runtime dependency MinVersion is required.', severity: 'error' }); + } else if (!isValidDotQuadNumber(dep.minVersion)) { + errors.push({ field: `${prefix}.minVersion`, message: 'MinVersion must be a DotQuadNumber (e.g. 1.0.0.0), each part 0–65535.', severity: 'error' }); + } + } + + for (let i = 0; i < data.dependencies.externalDependencies.length; i++) { + const dep = data.dependencies.externalDependencies[i]; + const prefix = `dependencies.externalDependency.${i}`; + + if (!dep.name) { + errors.push({ field: `${prefix}.name`, message: 'External dependency name is required.', severity: 'error' }); + } + + if (!dep.publisher) { + errors.push({ field: `${prefix}.publisher`, message: 'External dependency publisher is required.', severity: 'error' }); + } else if (!isValidPublisherDN(dep.publisher)) { + errors.push({ field: `${prefix}.publisher`, message: 'Publisher must be a valid X.500 distinguished name (e.g. CN=Contoso).', severity: 'error' }); + } + + if (!dep.minVersion) { + errors.push({ field: `${prefix}.minVersion`, message: 'External dependency MinVersion is required.', severity: 'error' }); + } else if (!isValidDotQuadNumber(dep.minVersion)) { + errors.push({ field: `${prefix}.minVersion`, message: 'MinVersion must be a DotQuadNumber (e.g. 1.0.0.0), each part 0–65535.', severity: 'error' }); + } + } +} + +function validateResources(data: ManifestData, errors: ValidationError[]): void { + const isResourcePackage = data.properties.resourcePackage?.toLowerCase() === 'true'; + for (let i = 0; i < data.resources.length; i++) { + const res = data.resources[i]; + if (res.language && !BCP47_REGEX.test(res.language)) { + errors.push({ field: `resources.${i}.language`, message: 'Language must be a valid BCP-47 tag (e.g. en, en-US, zh-Hans-CN) or x-generate.', severity: 'error' }); + } + + if (isResourcePackage) { + const filledAttrs = [ + res.language ? 'Language' : '', + res.scale ? 'Scale' : '', + res.dxFeatureLevel ? 'DXFeatureLevel' : '', + ].filter(Boolean); + if (filledAttrs.length > 1) { + const msg = 'Resource package resources must define only one attribute type (Language, Scale, or DXFeatureLevel).'; + if (res.language) errors.push({ field: `resources.${i}.language`, message: msg, severity: 'error' }); + if (res.scale) errors.push({ field: `resources.${i}.scale`, message: msg, severity: 'error' }); + if (res.dxFeatureLevel) errors.push({ field: `resources.${i}.dxFeatureLevel`, message: msg, severity: 'error' }); + } + } + } +} + +function validateApplications(data: ManifestData, errors: ValidationError[]): void { + for (let i = 0; i < data.applications.length; i++) { + const app = data.applications[i]; + const prefix = `applications.${i}`; + + if (!app.id) { + errors.push({ field: `${prefix}.id`, message: 'Application Id is required.', severity: 'error' }); + } else if (!APP_ID_REGEX.test(app.id)) { + errors.push({ field: `${prefix}.id`, message: 'Application Id must contain alpha-numeric fields separated by periods, each starting with a letter.', severity: 'error' }); + } else if (app.id.length > 64) { + errors.push({ field: `${prefix}.id`, message: 'Application Id must be 64 characters or fewer.', severity: 'error' }); + } else { + const idFields = app.id.split('.'); + const reservedField = idFields.find(f => RESERVED_NAMES.has(f.toUpperCase())); + if (reservedField) { + errors.push({ field: `${prefix}.id`, message: `Application Id cannot use reserved name "${reservedField}" as a field value.`, severity: 'error' }); + } + } + + if (!app.executable) { + errors.push({ field: `${prefix}.executable`, message: 'Executable path is required.', severity: 'error' }); + } else if (!app.executable.toLowerCase().endsWith('.exe')) { + errors.push({ field: `${prefix}.executable`, message: 'Executable must be an .exe file.', severity: 'error' }); + } + + if (!app.entryPoint) { + errors.push({ field: `${prefix}.entryPoint`, message: 'Entry point is required.', severity: 'error' }); + } + + if (!app.visualElements.displayName) { + errors.push({ field: `${prefix}.visualElements.displayName`, message: 'Display name is required.', severity: 'error' }); + } else if (app.visualElements.displayName.length > 256) { + errors.push({ field: `${prefix}.visualElements.displayName`, message: 'Display name must be 256 characters or fewer.', severity: 'error' }); + } + + if (app.visualElements.description && app.visualElements.description.length > 2048) { + errors.push({ field: `${prefix}.visualElements.description`, message: 'Description must be 2048 characters or fewer.', severity: 'error' }); + } else if (app.visualElements.description && /[\t\r\n]/.test(app.visualElements.description)) { + errors.push({ field: `${prefix}.visualElements.description`, message: 'Description cannot contain tabs, carriage returns, or line feeds.', severity: 'error' }); + } + + if (app.visualElements.backgroundColor && + !HEX_COLOR_REGEX.test(app.visualElements.backgroundColor) && + !NAMED_COLORS.has(app.visualElements.backgroundColor)) { + errors.push({ field: `${prefix}.visualElements.backgroundColor`, message: 'Background color must be a hex color (e.g. #FFFFFF), "transparent", or a named color (e.g. cornflowerBlue).', severity: 'error' }); + } + + const ve = app.visualElements; + const vePrefix = `${prefix}.visualElements`; + validateImageField(errors, `${vePrefix}.square150x150Logo`, ve.square150x150Logo); + validateImageField(errors, `${vePrefix}.square44x44Logo`, ve.square44x44Logo); + validateImageField(errors, `${vePrefix}.wide310x150Logo`, ve.wide310x150Logo); + validateImageField(errors, `${vePrefix}.square71x71Logo`, ve.square71x71Logo); + validateImageField(errors, `${vePrefix}.square310x310Logo`, ve.square310x310Logo); + validateImageField(errors, `${vePrefix}.badgeLogo`, ve.badgeLogo); + validateImageField(errors, `${vePrefix}.splashScreenImage`, ve.splashScreenImage); + + if (app.extensions && app.extensions.length > 0) { + for (let extIdx = 0; extIdx < app.extensions.length; extIdx++) { + const extXml = app.extensions[extIdx]; + const extFields = parseExtensionFieldsFromXml(extXml); + for (const field of extFields) { + const isRequired = REQUIRED_EXT_FIELDS.has(field.label); + const validation = validateExtensionField(field.label, field.value, isRequired); + if (validation) { + errors.push({ + field: `${prefix}.extensions.${extIdx}.${field.label}`, + message: validation.message, + severity: validation.level, + }); + } + } + } + } + } +} + +// ─── Extension Field Validation ───────────────────────────────────────────── + +export interface ExtFieldValidation { + level: 'error' | 'warning'; + message: string; +} + +// GUID regex that allows optional braces (CLSIDs typically have braces) +const EXT_GUID_REGEX = /^\{?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\}?$/; + +/** Required extension fields that must have a value. */ +const REQUIRED_EXT_FIELDS = new Set([ + 'ExeServer.Executable', 'ExeServer.DisplayName', 'Class.Id', + 'AppExtension.Name', 'AppExtension.Id', 'AppExtension.DisplayName', 'AppExtension.PublicFolder', + 'Registration', 'ExecutionAlias.Alias', + 'Extension.EntryPoint', 'Task.Type', + 'Protocol.Name', + 'FileTypeAssociation.Name', 'FileType', + 'StartupTask.TaskId', 'StartupTask.DisplayName', + 'DataFormat', + 'AppService.Name', + 'ToastNotificationActivation.ToastActivatorCLSID' +]); + +/** + * Validate an extension field value and return { level, message } or null if valid. + * This is the single source of truth for extension field validation. + */ +export function validateExtensionField(fieldLabel: string, value: string, isRequired: boolean): ExtFieldValidation | null { + // Required check first + if (isRequired && !value) { + return { level: 'error', message: 'This field is required.' }; + } + if (!value) { return null; } + + switch (fieldLabel) { + case 'Class.Id': + case 'ToastNotificationActivation.ToastActivatorCLSID': + if (!EXT_GUID_REGEX.test(value)) { + return { level: 'error', message: 'Must be a valid GUID, e.g., {12345678-1234-1234-1234-123456789012}' }; + } + break; + case 'ExecutionAlias.Alias': + if (!/\.exe$/i.test(value)) { + return { level: 'error', message: 'Alias must end with .exe (e.g., "myapp.exe").' }; + } + if (/[\\/:*?"<>|]/.test(value)) { + return { level: 'error', message: 'Alias must not contain path separators or special characters.' }; + } + break; + case 'Protocol.Name': + if (!/^[a-z][a-z0-9.+\-]*$/.test(value)) { + return { level: 'error', message: 'Protocol must start with a lowercase letter and contain only lowercase letters, digits, ".", "+", or "-".' }; + } + break; + case 'FileType': + if (!/^\.[a-zA-Z0-9]+$/.test(value)) { + return { level: 'error', message: 'File extension must start with "." followed by alphanumeric characters (e.g., ".txt").' }; + } + break; + case 'FileTypeAssociation.Name': + if (!/^[a-zA-Z0-9.]+$/.test(value)) { + return { level: 'error', message: 'Name must contain only letters, digits, and periods.' }; + } + break; + case 'StartupTask.Enabled': + if (value !== 'true' && value !== 'false') { + return { level: 'error', message: 'Value must be "true" or "false".' }; + } + break; + case 'ExeServer.Executable': + if (!/\.(exe|dll)$/i.test(value)) { + return { level: 'warning', message: 'Expected a .exe or .dll path.' }; + } + break; + case 'Task.Type': { + const validTypes = ['timer', 'pushNotification', 'systemEvent', 'general', 'audio', 'controlChannel', 'bluetooth', 'location', 'deviceUse', 'deviceServicing', 'deviceConnectionChange']; + if (!validTypes.includes(value)) { + return { level: 'warning', message: 'Common values: ' + validTypes.slice(0, 5).join(', ') + ', ...' }; + } + break; + } + case 'AppService.Name': + if (!/^[a-zA-Z][a-zA-Z0-9._]*$/.test(value)) { + return { level: 'warning', message: 'Recommended format: reverse-domain style (e.g., "com.contoso.myservice").' }; + } + break; + } + return null; +} + +/** + * Parse extension XML and extract editable fields with their labels and values. + * Simplified server-side version of the webview's parseExtensionFields(). + */ +function parseExtensionFieldsFromXml(xml: string): Array<{ label: string; value: string }> { + const fields: Array<{ label: string; value: string }> = []; + + // Extract attributes from XML elements (Element.Attribute="value") + // Match: patterns + const attrRegex = /<([a-zA-Z][a-zA-Z0-9]*)\s+([^>]*?)\/?>|<([a-zA-Z][a-zA-Z0-9]*)\s+([^>]*?)>/g; + let match: RegExpExecArray | null; + while ((match = attrRegex.exec(xml)) !== null) { + const elementName = match[1] || match[3]; + const attrString = match[2] || match[4]; + if (!attrString) continue; + + // Parse individual attributes from the attribute string + const attrItemRegex = /([a-zA-Z][a-zA-Z0-9]*)="([^"]*)"/g; + let attrMatch: RegExpExecArray | null; + while ((attrMatch = attrItemRegex.exec(attrString)) !== null) { + const attrName = attrMatch[1]; + const attrValue = attrMatch[2]; + // Skip xmlns and Category on root + if (attrName.startsWith('xmlns') || attrName === 'xmlns') continue; + if (attrName === 'Category') continue; + const fieldKey = elementName + '.' + attrName; + fields.push({ label: fieldKey, value: attrValue }); + } + } + + // Extract text content from leaf elements: text + const textContentRegex = /<([a-zA-Z][a-zA-Z0-9]*)(?:\s[^>]*)?>([^<]+)<\/\1>/g; + while ((match = textContentRegex.exec(xml)) !== null) { + const elementName = match[1]; + const textValue = match[2].trim(); + if (textValue) { + fields.push({ label: elementName, value: textValue }); + } + } + + return fields; +} + +/** Compare two version strings. Returns negative if a < b, 0 if equal, positive if a > b. */ +function compareVersions(a: string, b: string): number { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const na = pa[i] || 0; + const nb = pb[i] || 0; + if (na !== nb) { return na - nb; } + } + return 0; +} diff --git a/src/winapp-VSC/src/manifest-editor/manifest-xml-ops-applications.ts b/src/winapp-VSC/src/manifest-editor/manifest-xml-ops-applications.ts new file mode 100644 index 00000000..e0511a04 --- /dev/null +++ b/src/winapp-VSC/src/manifest-editor/manifest-xml-ops-applications.ts @@ -0,0 +1,257 @@ +/** + * Application and extension XML operations for the manifest editor. + * Split from manifest-xml-ops.ts for maintainability. + */ + +import { + NS, + escapeRegex, + ensureNamespace, + findParentBounds, + findDirectChildElementBounds, + findNthApplicationRegion, + removeNamespaceIfUnused, +} from './xml-utils'; + +import { removeNthChildByTag } from './manifest-xml-ops'; + +/** Add a new Application element to the manifest. */ +export function addApplication(xmlText: string): string { + let result = ensureNamespace(xmlText, 'uap', NS.uap); + + // Detect indentation from existing Application elements + const appIndentMatch = result.match(/^(\s+)\n' + + childIndent + '\n' + + appIndent + ''; + + // Insert before closing + const closeTag = ''; + const closeIdx = result.lastIndexOf(closeTag); + if (closeIdx < 0) { return result; } + + // Detect indent of from its line + const lineStart = result.lastIndexOf('\n', closeIdx - 1); + const appsIndent = lineStart >= 0 ? result.substring(lineStart + 1, closeIdx).match(/^(\s*)/)?.[1] ?? ' ' : ' '; + + const before = result.substring(0, closeIdx).replace(/\s+$/, ''); + return before + '\n' + template + '\n' + appsIndent + result.substring(closeIdx); +} + +/** Remove an Application element from the manifest. */ +export function removeApplication(xmlText: string, index: number): string { + const bounds = findParentBounds(xmlText, 'Applications'); + if (!bounds) { return xmlText; } + const children = findDirectChildElementBounds(xmlText, bounds.contentStart, bounds.contentEnd); + const apps = children.filter(c => /^'); + + let result = xmlText; + + // Ensure required namespace declarations + const nsMap: Record = { + 'com:': { prefix: 'com', uri: 'http://schemas.microsoft.com/appx/manifest/com/windows10' }, + 'uap:': { prefix: 'uap', uri: NS.uap }, + 'uap3:': { prefix: 'uap3', uri: 'http://schemas.microsoft.com/appx/manifest/uap/windows10/3' }, + 'uap5:': { prefix: 'uap5', uri: 'http://schemas.microsoft.com/appx/manifest/uap/windows10/5' }, + 'desktop:': { prefix: 'desktop', uri: 'http://schemas.microsoft.com/appx/manifest/desktop/windows10' }, + }; + for (const [prefix, ns] of Object.entries(nsMap)) { + if (extensionXml.includes(prefix)) { + result = ensureNamespace(result, ns.prefix, ns.uri); + } + } + + // Re-find the region since namespace insertion may have shifted positions + const updatedRegion = findNthApplicationRegion(result, appIndex); + if (!updatedRegion) { return result; } + const appCloseIdx = updatedRegion.end; + + // Detect the file's indentation by looking at existing content + let indentMatch = result.match(/^( +)/m); + if (!indentMatch) { + const appIndentMatch = result.match(/^( +) childIndent + line).join('\n'); + + if (hasExtensions) { + const closeTag = ''; + const closeIdx = result.lastIndexOf(closeTag, appCloseIdx); + if (closeIdx < 0) { return result; } + const beforeClose = result.substring(0, closeIdx).replace(/\s+$/, ''); + return beforeClose + '\n' + + indentedExt + '\n' + extIndent + + result.substring(closeIdx); + } else { + // Insert a new block before the closing tag + const closeAppTag = ''; + const closeIdx = result.lastIndexOf(closeAppTag, appCloseIdx); + if (closeIdx < 0) { return result; } + + const lineStart = result.lastIndexOf('\n', closeIdx - 1); + const appIndent = lineStart >= 0 ? result.substring(lineStart + 1, closeIdx).match(/^(\s*)/)?.[1] ?? ' ' : ' '; + const extBlockIndent = appIndent + ' '; + const before = result.substring(0, closeIdx).replace(/\s+$/, ''); + const block = '\n' + extBlockIndent + '\n' + + indentedExt + '\n' + + extBlockIndent + '\n' + + appIndent; + return before + block + result.substring(closeIdx); + } +} + +/** Remove an extension element from an application (string-based to preserve formatting). */ +export function removeExtension(xmlText: string, appIndex: number, extIndex: number): string { + // Use findNthApplicationRegion for reliable application boundary detection + const appRegion = findNthApplicationRegion(xmlText, appIndex); + if (!appRegion) { return xmlText; } + const appCloseIdx = appRegion.end; + + // Find and within this Application + const extOpenTag = ''; + const extCloseTag = ''; + const extOpenIdx = xmlText.lastIndexOf(extOpenTag, appCloseIdx); + if (extOpenIdx < 0 || extOpenIdx < appRegion.start) { return xmlText; } + const extCloseIdx = xmlText.indexOf(extCloseTag, extOpenIdx); + if (extCloseIdx < 0 || extCloseIdx > appCloseIdx) { return xmlText; } + + const contentStart = extOpenIdx + extOpenTag.length; + const contentEnd = extCloseIdx; + + // Find all direct child elements within ... + const children = findDirectChildElementBounds(xmlText, contentStart, contentEnd); + if (extIndex < 0 || extIndex >= children.length) { return xmlText; } + + const target = children[extIndex]; + + // Capture the namespace prefix of the extension being removed + const targetXml = xmlText.substring(target.start, target.end); + const extNsMatch = /^<([a-zA-Z0-9]+):/.exec(targetXml); + const extNsPrefix = extNsMatch ? extNsMatch[1] : null; + + // Expand removal range to include leading whitespace (indentation) and trailing newline + let removeStart = target.start; + while (removeStart > contentStart && (xmlText[removeStart - 1] === ' ' || xmlText[removeStart - 1] === '\t')) { + removeStart--; + } + // Also consume the preceding newline + if (removeStart > contentStart && xmlText[removeStart - 1] === '\n') { + removeStart--; + if (removeStart > contentStart && xmlText[removeStart - 1] === '\r') { + removeStart--; + } + } + + let result = xmlText.substring(0, removeStart) + xmlText.substring(target.end); + + // Check if Extensions is now empty (no more child elements) + const newExtOpenIdx = result.lastIndexOf(extOpenTag, appCloseIdx); + if (newExtOpenIdx >= 0) { + const newExtCloseIdx = result.indexOf(extCloseTag, newExtOpenIdx); + if (newExtCloseIdx >= 0) { + const innerContent = result.substring(newExtOpenIdx + extOpenTag.length, newExtCloseIdx); + if (innerContent.trim() === '') { + // Remove the entire ... block including surrounding whitespace + let blockStart = newExtOpenIdx; + while (blockStart > 0 && (result[blockStart - 1] === ' ' || result[blockStart - 1] === '\t')) { + blockStart--; + } + if (blockStart > 0 && result[blockStart - 1] === '\n') { + blockStart--; + if (blockStart > 0 && result[blockStart - 1] === '\r') { + blockStart--; + } + } + const blockEnd = newExtCloseIdx + extCloseTag.length; + result = result.substring(0, blockStart) + result.substring(blockEnd); + } + } + } + + // Clean up namespace if no longer used + if (extNsPrefix) { + result = removeNamespaceIfUnused(result, extNsPrefix); + } + + return result; +} + +/** + * Update an attribute on an extension element. + * fieldPath is "ElementName.AttributeName" as produced by parseExtensionFields in the webview. + */ +export function updateExtensionField( + xmlText: string, appIndex: number, extIndex: number, fieldPath: string, value: string, isTextContent?: boolean, +): string { + // Use findNthApplicationRegion for reliable application boundary detection + const appRegion = findNthApplicationRegion(xmlText, appIndex); + if (!appRegion) { return xmlText; } + const appCloseIdx = appRegion.end; + + // Find and within this Application + const extOpenTag = ''; + const extCloseTag = ''; + const extOpenIdx = xmlText.lastIndexOf(extOpenTag, appCloseIdx); + if (extOpenIdx < 0 || extOpenIdx < appRegion.start) { return xmlText; } + const extCloseIdx = xmlText.indexOf(extCloseTag, extOpenIdx); + if (extCloseIdx < 0 || extCloseIdx > appCloseIdx) { return xmlText; } + + const contentStart = extOpenIdx + extOpenTag.length; + const contentEnd = extCloseIdx; + + // Find all direct child elements within + const children = findDirectChildElementBounds(xmlText, contentStart, contentEnd); + if (extIndex < 0 || extIndex >= children.length) { return xmlText; } + + const target = children[extIndex]; + let extXml = xmlText.substring(target.start, target.end); + + if (isTextContent) { + // fieldPath is just the element name — find text + const elemPattern = new RegExp( + `(<(?:[a-zA-Z0-9]+:)?${escapeRegex(fieldPath)}\\b[^>]*>)([\\s\\S]*?)(<\\/(?:[a-zA-Z0-9]+:)?${escapeRegex(fieldPath)}\\s*>)` + ); + const match = elemPattern.exec(extXml); + if (!match) { return xmlText; } + extXml = extXml.substring(0, match.index) + match[1] + value + match[3] + extXml.substring(match.index + match[0].length); + } else { + const dotIdx = fieldPath.indexOf('.'); + if (dotIdx < 0) { return xmlText; } + const elemName = fieldPath.substring(0, dotIdx); + const attrName = fieldPath.substring(dotIdx + 1); + + // Find the element's opening tag within the extension XML + const elemPattern = new RegExp(`<(?:[a-zA-Z0-9]+:)?${escapeRegex(elemName)}\\b[^>]*\\/?>`, 's'); + const match = elemPattern.exec(extXml); + if (!match) { return xmlText; } + + // Replace the attribute value within the matched element tag + const attrRegex = new RegExp(`(${escapeRegex(attrName)}\\s*=\\s*)(["'])((?:(?!\\2).)*?)\\2`); + const attrMatch = attrRegex.exec(match[0]); + if (!attrMatch) { return xmlText; } + + const newElem = match[0].substring(0, attrMatch.index) + + attrMatch[1] + attrMatch[2] + value + attrMatch[2] + + match[0].substring(attrMatch.index + attrMatch[0].length); + extXml = extXml.substring(0, match.index) + newElem + extXml.substring(match.index + match[0].length); + } + + return xmlText.substring(0, target.start) + extXml + xmlText.substring(target.end); +} diff --git a/src/winapp-VSC/src/manifest-editor/manifest-xml-ops.ts b/src/winapp-VSC/src/manifest-editor/manifest-xml-ops.ts new file mode 100644 index 00000000..075e15c1 --- /dev/null +++ b/src/winapp-VSC/src/manifest-editor/manifest-xml-ops.ts @@ -0,0 +1,794 @@ +/** + * XML surgery/mutation operations for the manifest editor. + * All functions perform string-based manipulation to preserve formatting. + */ + +import { + NS, + CAPABILITY_NS_URIS, + escapeRegex, + escapeXmlAttr, + findParentBounds, + findDirectChildElementBounds, + ensureNamespace, + removeNamespaceIfUnused, + swapAdjacentElements, + findNthApplicationRegion, + detectIndent, + getCapabilityElementInfo, +} from './xml-utils'; + +import type { + PackageDependencyData, + TargetDeviceFamilyData, + MainPackageDependencyData, + DriverConstraintData, + OSPackageDependencyData, + HostRuntimeDependencyData, + ExternalDependencyData, + ResourceData, +} from './manifest-types'; + +// ─── Internal helpers ─────────────────────────────────────────────── + +/** + * Find the bounds of a package-level element (direct child of ), skipping + * any matches that are nested inside . + * Falls back to findParentBounds if no section exists. + */ +function findPackageLevelParentBounds(xml: string, localName: string): { openStart: number; contentStart: number; contentEnd: number; closeEnd: number } | null { + // Find the region to exclude + const appsBounds = findParentBounds(xml, 'Applications'); + + const openPattern = new RegExp(`<(?:[a-zA-Z0-9]+:)?${escapeRegex(localName)}\\b`, 'g'); + let match: RegExpExecArray | null; + while ((match = openPattern.exec(xml)) !== null) { + const openStart = match.index; + + // Skip matches inside the region + if (appsBounds && openStart > appsBounds.openStart && openStart < appsBounds.closeEnd) { + continue; + } + + const gt = xml.indexOf('>', openStart); + if (gt === -1) { continue; } + if (xml[gt - 1] === '/') { continue; } // self-closing + const contentStart = gt + 1; + const closePattern = new RegExp(``); + const closeMatch = closePattern.exec(xml.substring(contentStart)); + if (!closeMatch) { continue; } + const contentEnd = contentStart + closeMatch.index; + const closeEnd = contentEnd + closeMatch[0].length; + return { openStart, contentStart, contentEnd, closeEnd }; + } + return null; +} + +/** + * Expand a self-closing element like `` into `\n`. + * Returns the original XML unchanged if the element is not self-closing or not found. + */ +function expandSelfClosingElement(xml: string, localName: string): string { + const pattern = new RegExp(`(<(?:[a-zA-Z0-9]+:)?${escapeRegex(localName)}\\b[^>]*)\\s*/>`); + const match = pattern.exec(xml); + if (!match) { return xml; } + const tagName = match[0].match(/<([a-zA-Z0-9:]+)/)?.[1] ?? localName; + const indent = detectIndent(xml, match.index); + return xml.substring(0, match.index) + + match[1] + '>\n' + + indent + `` + + xml.substring(match.index + match[0].length); +} + +/** Remove an element and its leading whitespace/newline from the XML string. */ +function removeElementWithWhitespace(xml: string, elemStart: number, elemEnd: number, containerContentStart: number): string { + let removeStart = elemStart; + while (removeStart > containerContentStart && (xml[removeStart - 1] === ' ' || xml[removeStart - 1] === '\t')) { + removeStart--; + } + if (removeStart > containerContentStart && xml[removeStart - 1] === '\n') { + removeStart--; + if (removeStart > containerContentStart && xml[removeStart - 1] === '\r') { + removeStart--; + } + } + return xml.substring(0, removeStart) + xml.substring(elemEnd); +} + +/** Insert a child element before a closing tag with proper indentation. */ +export function insertChildBeforeClose(xml: string, closeTagPos: number, childXml: string, parentIndent: string): string { + const childIndent = parentIndent + ' '; + let lineStart = closeTagPos; + while (lineStart > 0 && xml[lineStart - 1] !== '\n') { lineStart--; } + return xml.substring(0, lineStart) + childIndent + childXml + '\n' + xml.substring(lineStart); +} + +/** Check if an element tag string matches the expected capability namespace prefix. */ +function matchesCapabilityTag(elemXml: string, capNs: string): boolean { + if (capNs === 'device') { return /^ 0) { + return { attrName: capability.substring(colonIdx + 1), namespace: capability.substring(0, colonIdx) }; + } + // Custom capability: company.name_publisherId → uap4:CustomCapability + if (/^[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)+_[a-z0-9]{13}$/.test(capability)) { + return { attrName: capability, namespace: 'uap4:custom' }; + } + return { attrName: capability, namespace: '' }; +} + +/** Remove nth child matching tagPattern within a parent section. */ +export function removeNthChildByTag(xmlText: string, parentLocalName: string, tagPattern: RegExp, index: number, findParent?: typeof findParentBounds): string { + const find = findParent ?? findParentBounds; + const bounds = find(xmlText, parentLocalName); + if (!bounds) { return xmlText; } + const children = findDirectChildElementBounds(xmlText, bounds.contentStart, bounds.contentEnd); + const items = children.filter(c => tagPattern.test(xmlText.substring(c.start, c.end))); + if (index < 0 || index >= items.length) { return xmlText; } + return removeElementWithWhitespace(xmlText, items[index].start, items[index].end, bounds.contentStart); +} + +/** Move nth child matching tagPattern within a parent section. */ +function moveNthChildByTag(xmlText: string, parentLocalName: string, tagPattern: RegExp, index: number, direction: 'up' | 'down', findParent?: typeof findParentBounds): string { + const find = findParent ?? findParentBounds; + const bounds = find(xmlText, parentLocalName); + if (!bounds) { return xmlText; } + const children = findDirectChildElementBounds(xmlText, bounds.contentStart, bounds.contentEnd); + const items = children.filter(c => tagPattern.test(xmlText.substring(c.start, c.end))); + const swapIdx = direction === 'up' ? index - 1 : index + 1; + if (index < 0 || index >= items.length || swapIdx < 0 || swapIdx >= items.length) { return xmlText; } + return swapAdjacentElements(xmlText, items[index], items[swapIdx]); +} + +/** Insert a child element into a section, creating the section if needed. */ +function addChildToSection(xmlText: string, sectionName: string, childXml: string, opts?: { expandSelfClosing?: boolean; findParent?: typeof findParentBounds; createIfMissing?: boolean }): string { + let result = xmlText; + if (opts?.expandSelfClosing) { + result = expandSelfClosingElement(result, sectionName); + } + const find = opts?.findParent ?? findParentBounds; + const bounds = find(result, sectionName); + if (bounds) { + const parentIndent = detectIndent(result, bounds.openStart); + return insertChildBeforeClose(result, bounds.contentEnd, childXml, parentIndent); + } + if (opts?.createIfMissing === false) { return result; } + const pkgClose = result.lastIndexOf(''); + if (pkgClose < 0) { return result; } + const pkgIndent = detectIndent(result, pkgClose); + const parentIndent = pkgIndent + ' '; + const block = parentIndent + '<' + sectionName + '>\n' + + parentIndent + ' ' + childXml + '\n' + + parentIndent + '\n'; + let lineStart = pkgClose; + while (lineStart > 0 && result[lineStart - 1] !== '\n') { lineStart--; } + return result.substring(0, lineStart) + block + result.substring(lineStart); +} + +/** Ensure a prefix is listed in the IgnorableNamespaces attribute on Package. */ +function ensureIgnorableNamespace(xmlText: string, prefix: string): string { + const pkgMatch = /]*>/s.exec(xmlText); + if (!pkgMatch) { return xmlText; } + const pkgTag = pkgMatch[0]; + + const ignorableMatch = /IgnorableNamespaces="([^"]*)"/.exec(pkgTag); + if (ignorableMatch) { + const namespaces = ignorableMatch[1].split(/\s+/); + if (namespaces.includes(prefix)) { return xmlText; } + const newAttr = `IgnorableNamespaces="${ignorableMatch[1]} ${prefix}"`; + const newTag = pkgTag.replace(ignorableMatch[0], newAttr); + return xmlText.substring(0, pkgMatch.index) + newTag + xmlText.substring(pkgMatch.index + pkgTag.length); + } + + // No IgnorableNamespaces attribute — add one + const pkgTagInner = /]*(?:\/>|>)/gs; + let veMatch: RegExpExecArray | null; + let count = 0; + while ((veMatch = vePattern.exec(xml)) !== null) { + if (count === appIndex) { break; } + count++; + } + if (!veMatch || count !== appIndex) { return xml; } + + const veStart = veMatch.index; + const veClosePattern = /<\/[a-zA-Z0-9]*:?VisualElements\s*>/; + const afterVe = xml.substring(veStart); + const veCloseMatch = veClosePattern.exec(afterVe); + const veEndPos = veCloseMatch ? veStart + veCloseMatch.index + veCloseMatch[0].length : xml.length; + const veBlock = xml.substring(veStart, veEndPos); + + // Match open/close DefaultTile with only whitespace inside + const emptyDtPattern = /(<([a-zA-Z0-9]*:?DefaultTile)\b[^>]*)>\s*<\/\2\s*>/s; + const emptyDtMatch = emptyDtPattern.exec(veBlock); + if (emptyDtMatch) { + const absPos = veStart + emptyDtMatch.index; + const selfClosing = emptyDtMatch[1] + ' />'; + xml = xml.substring(0, absPos) + selfClosing + xml.substring(absPos + emptyDtMatch[0].length); + } + + return xml; +} + +// ─── Exported operations ──────────────────────────────────────────── + +/** Add a capability element to the XML. */ +export function addCapability(xmlText: string, capability: string): string { + const { elementName, attrName } = getCapabilityElementInfo(capability); + const childXml = `<${elementName} Name="${escapeXmlAttr(attrName)}" />`; + + let result = xmlText; + + // Ensure namespace is declared for prefixed capabilities + const colonIdx = capability.indexOf(':'); + if (colonIdx > 0 && !capability.startsWith('device:')) { + const prefix = capability.substring(0, colonIdx); + const nsUri = CAPABILITY_NS_URIS[prefix]; + if (nsUri) { + result = ensureNamespace(result, prefix, nsUri); + } + } + + // Custom capabilities (no prefix) need uap4 namespace for uap4:CustomCapability element + if (elementName === 'uap4:CustomCapability') { + result = ensureNamespace(result, 'uap4', CAPABILITY_NS_URIS['uap4']); + } + + return addChildToSection(result, 'Capabilities', childXml, { expandSelfClosing: true, findParent: findPackageLevelParentBounds }); +} + +/** Remove a capability element from the XML. */ +export function removeCapability(xmlText: string, capability: string): string { + const bounds = findPackageLevelParentBounds(xmlText, 'Capabilities'); + if (!bounds) { return xmlText; } + + const { attrName, namespace: capNs } = parseCapabilityString(capability); + const children = findDirectChildElementBounds(xmlText, bounds.contentStart, bounds.contentEnd); + + // Determine which tag patterns to try. For unprefixed capabilities (capNs === '' + // or 'uap4:custom'), also check uap4:CustomCapability since the parser stores + // CustomCapability elements without a prefix. + const tagsToTry: string[] = [capNs]; + if (capNs === '' || capNs === 'uap4:custom') { + if (!tagsToTry.includes('')) { tagsToTry.push(''); } + if (!tagsToTry.includes('uap4:custom')) { tagsToTry.push('uap4:custom'); } + } + + // Search backwards (last match first, same as original behavior) + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i]; + const childXml = xmlText.substring(child.start, child.end); + if (!hasNameAttribute(childXml, attrName)) { continue; } + if (!tagsToTry.some(ns => matchesCapabilityTag(childXml, ns))) { continue; } + const result = removeElementWithWhitespace(xmlText, child.start, child.end, bounds.contentStart); + // Clean up the namespace declaration if no longer used + if (capNs && capNs !== 'uap4:custom') { + const nsPrefix = capNs.includes(':') ? capNs.split(':')[0] : capNs; + if (CAPABILITY_NS_URIS[nsPrefix]) { + return removeNamespaceIfUnused(result, nsPrefix); + } + } + return result; + } + + return xmlText; +} + +/** Add a PackageDependency element. */ +export function addPackageDependency(xmlText: string, dep: PackageDependencyData): string { + let result = xmlText; + let attrs = `Name="${escapeXmlAttr(dep.name)}"`; + if (dep.minVersion) { attrs += ` MinVersion="${escapeXmlAttr(dep.minVersion)}"`; } + if (dep.publisher) { attrs += ` Publisher="${escapeXmlAttr(dep.publisher)}"`; } + if (dep.optional === 'true' || dep.optional === 'false') { + attrs += ` uap6:Optional="${dep.optional}"`; + result = ensureNamespace(result, 'uap6', 'http://schemas.microsoft.com/appx/manifest/uap/windows10/6'); + } + const childXml = ``; + return addChildToSection(result, 'Dependencies', childXml, { expandSelfClosing: true }); +} + +/** Remove a PackageDependency element by index. */ +export function removePackageDependency(xmlText: string, index: number): string { + return removeNthChildByTag(xmlText, 'Dependencies', /^`; + return addChildToSection(xmlText, 'Dependencies', childXml); +} + +/** Remove a TargetDeviceFamily element by index. */ +export function removeTargetDeviceFamily(xmlText: string, index: number): string { + return removeNthChildByTag(xmlText, 'Dependencies', /^`; + return addChildToSection(result, 'Dependencies', childXml, { createIfMissing: false }); +} + +/** Remove a uap3:MainPackageDependency by index. */ +export function removeMainPackageDependency(xmlText: string, index: number): string { + const result = removeNthChildByTag(xmlText, 'Dependencies', /^ /^`; + + if (driverDeps.length === 0) { + // Create wrapper with the constraint inside + const parentIndent = detectIndent(result, bounds.openStart); + const childIndent = parentIndent + ' '; + const grandchildIndent = childIndent + ' '; + const wrapperXml = `\n${grandchildIndent}${constraintXml}\n${childIndent}`; + return insertChildBeforeClose(result, bounds.contentEnd, wrapperXml, parentIndent); + } + + // Append to the first (only) DriverDependency + const dd = driverDeps[0]; + const ddText = result.substring(dd.start, dd.end); + const closeTag = ''; + const closeIdx = ddText.lastIndexOf(closeTag); + if (closeIdx < 0) { return result; } + const closePos = dd.start + closeIdx; + const ddIndent = detectIndent(result, dd.start); + const constraintIndent = ddIndent + ' '; + + // Walk back to find start of whitespace before the close tag + let wsStart = closePos; + while (wsStart > 0 && result[wsStart - 1] !== '\n') { wsStart--; } + + return result.substring(0, wsStart) + constraintIndent + constraintXml + '\n' + ddIndent + result.substring(closePos); +} + +/** Remove a uap5:DriverConstraint by flat index. Removes the DriverDependency wrapper if empty. */ +export function removeDriverConstraint(xmlText: string, index: number): string { + const bounds = findParentBounds(xmlText, 'Dependencies'); + if (!bounds) { return xmlText; } + const children = findDirectChildElementBounds(xmlText, bounds.contentStart, bounds.contentEnd); + const driverDeps = children.filter(c => /^', dd.start) + 1; + const ddContentEnd = xmlText.lastIndexOf('', dd.end); + if (ddContentEnd < 0) { continue; } + const constraints = findDirectChildElementBounds(xmlText, ddContentStart, ddContentEnd); + const dcItems = constraints.filter(c => /^ /^') { + result = removeElementWithWhitespace(result, wrapper.start, wrapper.end, newBounds.contentStart); + break; + } + } + } + } + return removeNamespaceIfUnused(result, 'uap5'); + } + flatIdx++; + } + } + return xmlText; +} + +/** Collect all uap5:DriverConstraint elements across all DriverDependency wrappers. */ +function collectDriverConstraints(xmlText: string): { start: number; end: number }[] { + const bounds = findParentBounds(xmlText, 'Dependencies'); + if (!bounds) { return []; } + const children = findDirectChildElementBounds(xmlText, bounds.contentStart, bounds.contentEnd); + const driverDeps = children.filter(c => /^', dd.start) + 1; + const ddContentEnd = xmlText.lastIndexOf('', dd.end); + if (ddContentEnd < 0) { continue; } + const constraints = findDirectChildElementBounds(xmlText, ddContentStart, ddContentEnd); + all.push(...constraints.filter(c => /^= allConstraints.length || swapIdx < 0 || swapIdx >= allConstraints.length) { return xmlText; } + return swapAdjacentElements(xmlText, allConstraints[index], allConstraints[swapIdx]); +} + +// ── OSPackageDependency (uap7) ── + +/** Add a uap7:OSPackageDependency element. */ +export function addOSPackageDependency(xmlText: string, dep: OSPackageDependencyData): string { + let result = ensureNamespace(xmlText, 'uap7', NS.uap7); + let attrs = `Name="${escapeXmlAttr(dep.name)}"`; + if (dep.version) { attrs += ` Version="${escapeXmlAttr(dep.version)}"`; } + const childXml = ``; + return addChildToSection(result, 'Dependencies', childXml, { createIfMissing: false }); +} + +/** Remove a uap7:OSPackageDependency by index. */ +export function removeOSPackageDependency(xmlText: string, index: number): string { + const result = removeNthChildByTag(xmlText, 'Dependencies', /^`; + return addChildToSection(result, 'Dependencies', childXml, { createIfMissing: false }); +} + +/** Remove a uap10:HostRuntimeDependency by index. */ +export function removeHostRuntimeDependency(xmlText: string, index: number): string { + const result = removeNthChildByTag(xmlText, 'Dependencies', /^`; + return addChildToSection(result, 'Dependencies', childXml, { createIfMissing: false }); +} + +/** Remove a win32dependencies:ExternalDependency by index. */ +export function removeExternalDependency(xmlText: string, index: number): string { + const result = removeNthChildByTag(xmlText, 'Dependencies', /^`; + return addChildToSection(result, 'Resources', childXml, { expandSelfClosing: true }); +} + +/** Remove a Resource element by index. */ +export function removeResource(xmlText: string, index: number): string { + return removeNthChildByTag(xmlText, 'Resources', /^`; + + // Insert after element + const identityPattern = /<(?:[a-zA-Z0-9]+:)?Identity\b[^>]*\/>/s; + const identityMatch = identityPattern.exec(result); + if (identityMatch) { + const insertPos = identityMatch.index + identityMatch[0].length; + const indent = detectIndent(result, identityMatch.index); + return result.substring(0, insertPos) + '\n' + indent + phoneElement + result.substring(insertPos); + } + + // Fallback: insert before + const pkgClose = result.lastIndexOf(''); // lgtm[js/incomplete-multi-character-sanitization] + if (pkgClose < 0) { return result; } + const pkgIndent = detectIndent(result, pkgClose); + const childIndent = pkgIndent + ' '; + let lineStart = pkgClose; + while (lineStart > 0 && result[lineStart - 1] !== '\n') { lineStart--; } + return result.substring(0, lineStart) + childIndent + phoneElement + '\n' + result.substring(lineStart); +} + +/** Remove the mp:PhoneIdentity element from the manifest. */ +export function removePhoneIdentity(xmlText: string): string { + const pattern = /[ \t]*<[a-zA-Z0-9]*:?PhoneIdentity\b[^>]*(?:\/>|>[^<]*<\/[a-zA-Z0-9]*:?PhoneIdentity\s*>)[ \t]*\r?\n?/s; + const match = pattern.exec(xmlText); + if (!match) { return xmlText; } + const result = xmlText.substring(0, match.index) + xmlText.substring(match.index + match[0].length); + return removeNamespaceIfUnused(result, 'mp'); +} + +/** Set the ShowNameOnTiles entries for an application by index. + * `tiles` is an array of tile values like ['square150x150Logo', 'wide310x150Logo']. + * An empty array removes ShowNameOnTiles entirely. */ +export function setShowNameOnTiles(xmlText: string, appIndex: number, tiles: string[]): string { + let xml = xmlText; + + // Find the nth Application's VisualElements + const vePattern = /<[a-zA-Z0-9]*:?VisualElements\b[^>]*(?:\/>|>)/gs; + let veMatch: RegExpExecArray | null; + let count = 0; + let veMatchResult: RegExpExecArray | null = null; + while ((veMatch = vePattern.exec(xml)) !== null) { + if (count === appIndex) { veMatchResult = veMatch; break; } + count++; + } + if (!veMatchResult) { return xml; } + + // Find the DefaultTile within this Application's scope + const veStart = veMatchResult.index; + // Find the end of VisualElements (closing tag) + const veClosePattern = /<\/[a-zA-Z0-9]*:?VisualElements\s*>/; + const afterVe = xml.substring(veStart); + const veCloseMatch = veClosePattern.exec(afterVe); + const veEndPos = veCloseMatch ? veStart + veCloseMatch.index + veCloseMatch[0].length : xml.length; + const veBlock = xml.substring(veStart, veEndPos); + + // Check if DefaultTile exists in this VisualElements block + const dtPattern = /<[a-zA-Z0-9]*:?DefaultTile\b/; + const hasDT = dtPattern.test(veBlock); + + if (!hasDT) { + if (tiles.length === 0) { return xml; } + // No DefaultTile yet — create one with ShowNameOnTiles inside, before + if (!veCloseMatch) { return xml; } + const veIndentMatch = xml.substring(0, veStart).match(/([ \t]*)$/); + const veIndent = veIndentMatch ? veIndentMatch[1] : ' '; + const dtIndent = veIndent + ' '; + const childIndent = dtIndent + ' '; + const showOnIndent = childIndent + ' '; + let showNameXml = childIndent + '\n'; + for (const tile of tiles) { + showNameXml += showOnIndent + `\n`; + } + showNameXml += childIndent + '\n'; + const newDt = dtIndent + '\n' + showNameXml + dtIndent + '\n'; + // Insert before the line containing + const closeAbsPos = veStart + veCloseMatch.index; + let lineStart = closeAbsPos; + while (lineStart > 0 && xml[lineStart - 1] !== '\n') { lineStart--; } + xml = xml.substring(0, lineStart) + newDt + xml.substring(lineStart); + return xml; + } + + // Find the existing ShowNameOnTiles block within this VE block (if any) + const showNamePattern = /[ \t]*<[a-zA-Z0-9]*:?ShowNameOnTiles\b[\s\S]*?<\/[a-zA-Z0-9]*:?ShowNameOnTiles\s*>\s*/; + const showNameMatch = showNamePattern.exec(veBlock); + + if (tiles.length === 0) { + // Remove existing ShowNameOnTiles if present + if (showNameMatch) { + const absStart = veStart + showNameMatch.index; + // Include preceding newline + let removeStart = absStart; + if (removeStart > 0 && xml[removeStart - 1] === '\n') { removeStart--; } + xml = xml.substring(0, removeStart) + xml.substring(absStart + showNameMatch[0].length); + + // Check if DefaultTile now has no children — convert back to self-closing + xml = collapseEmptyDefaultTile(xml, appIndex); + } + return xml; + } + + // Build ShowNameOnTiles XML + const dtIndentMatch = veBlock.match(/\n([ \t]*)<[a-zA-Z0-9]*:?DefaultTile\b/); + const dtIndent = dtIndentMatch ? dtIndentMatch[1] : ' '; + const childIndent = dtIndent + ' '; + const showOnIndent = childIndent + ' '; + + let showNameXml = childIndent + '\n'; + for (const tile of tiles) { + showNameXml += showOnIndent + `\n`; + } + showNameXml += childIndent + ''; + + if (showNameMatch) { + // Replace existing ShowNameOnTiles — match includes leading [ \t]* and trailing \s* + const absStart = veStart + showNameMatch.index; + const absEnd = absStart + showNameMatch[0].length; + xml = xml.substring(0, absStart) + showNameXml + '\n' + dtIndent + xml.substring(absEnd); + } else { + // Insert ShowNameOnTiles — need to handle self-closing vs open DefaultTile + const dtSelfClose = /<([a-zA-Z0-9]*:?DefaultTile)\b([^>]*?)\/>/s; + const dtSelfMatch = dtSelfClose.exec(veBlock); + if (dtSelfMatch) { + // Convert self-closing DefaultTile to open/close with ShowNameOnTiles inside + const absPos = veStart + dtSelfMatch.index; + const prefix = dtSelfMatch[1]; + const attrs = dtSelfMatch[2]; + const newDt = `<${prefix}${attrs}>\n` + + showNameXml + '\n' + + dtIndent + ``; + xml = xml.substring(0, absPos) + newDt + xml.substring(absPos + dtSelfMatch[0].length); + } else { + // Open DefaultTile — insert before closing tag + const dtClosePattern = /<\/[a-zA-Z0-9]*:?DefaultTile\s*>/; + const dtCloseMatch = dtClosePattern.exec(veBlock); + if (dtCloseMatch) { + const absPos = veStart + dtCloseMatch.index; + xml = xml.substring(0, absPos) + showNameXml + '\n' + dtIndent + xml.substring(absPos); + } + } + } + + return xml; +} + +/** + * Remove an optional visual asset from the nth Application. + * For DefaultTile attributes (wide310x150Logo, square71x71Logo, square310x310Logo): + * removes the attribute, and if DefaultTile has no remaining attributes, removes the element. + * For LockScreen (badgeLogo): removes the entire LockScreen element. + * For SplashScreen (splashScreenImage): removes the entire SplashScreen element. + */ +export function removeVisualAsset(xmlText: string, appIndex: number, veField: string): string { + const appRegion = findNthApplicationRegion(xmlText, appIndex); + if (!appRegion) { return xmlText; } + const appXml = xmlText.substring(appRegion.start, appRegion.end); + + const defaultTileAttrs: Record = { + wide310x150Logo: 'Wide310x150Logo', + square71x71Logo: 'Square71x71Logo', + square310x310Logo: 'Square310x310Logo', + }; + + if (defaultTileAttrs[veField]) { + // Remove the attribute from DefaultTile + const dtPattern = /<[a-zA-Z0-9]*:?DefaultTile\b[^>]*?\/?>/s; + const dtMatch = dtPattern.exec(appXml); + if (!dtMatch) { return xmlText; } + const attrName = defaultTileAttrs[veField]; + const attrPattern = new RegExp(`\\s*${attrName}="[^"]*"`); + const newDtTag = dtMatch[0].replace(attrPattern, ''); + let result = xmlText.substring(0, appRegion.start) + appXml.replace(dtMatch[0], newDtTag) + xmlText.substring(appRegion.end); + // If DefaultTile has no remaining content attributes, remove the element entirely + const cleanDtMatch = dtPattern.exec(result.substring(appRegion.start, appRegion.start + appXml.length + 50)); + if (cleanDtMatch) { + const defaultTileTagMatch = /^<[a-zA-Z0-9]*:?DefaultTile\b([^>]*)\/?>$/s.exec(cleanDtMatch[0]); + const tagContent = (defaultTileTagMatch?.[1] ?? '').trim(); + if (!tagContent) { + // Remove the entire DefaultTile element and its surrounding whitespace + const absStart = appRegion.start + cleanDtMatch.index; + let removeStart = absStart; + while (removeStart > 0 && (result[removeStart - 1] === ' ' || result[removeStart - 1] === '\t')) { removeStart--; } + if (removeStart > 0 && result[removeStart - 1] === '\n') { removeStart--; } + if (removeStart > 0 && result[removeStart - 1] === '\r') { removeStart--; } + result = result.substring(0, removeStart) + result.substring(absStart + cleanDtMatch[0].length); + } + } + return result; + } + + if (veField === 'badgeLogo') { + // Remove the entire LockScreen element + const lsPattern = /[ \t]*<[a-zA-Z0-9]*:?LockScreen\b[^>]*?\/?>[^\n]*\r?\n?/s; + const lsMatch = lsPattern.exec(appXml); + if (!lsMatch) { return xmlText; } + const absPos = appRegion.start + lsMatch.index; + return xmlText.substring(0, absPos) + xmlText.substring(absPos + lsMatch[0].length); + } + + if (veField === 'splashScreenImage') { + // Remove the entire SplashScreen element + const ssPattern = /[ \t]*<[a-zA-Z0-9]*:?SplashScreen\b[^>]*?\/?>[^\n]*\r?\n?/s; + const ssMatch = ssPattern.exec(appXml); + if (!ssMatch) { return xmlText; } + const absPos = appRegion.start + ssMatch.index; + return xmlText.substring(0, absPos) + xmlText.substring(absPos + ssMatch[0].length); + } + + return xmlText; +} + +// ─── Re-exports from split files ──────────────────────────────────── +export { addApplication, removeApplication, addExtension, removeExtension, updateExtensionField } from './manifest-xml-ops-applications'; diff --git a/src/winapp-VSC/src/manifest-editor/webview-content.ts b/src/winapp-VSC/src/manifest-editor/webview-content.ts new file mode 100644 index 00000000..c32c26a6 --- /dev/null +++ b/src/winapp-VSC/src/manifest-editor/webview-content.ts @@ -0,0 +1,453 @@ +/** + * Generates the HTML content for the AppxManifest editor webview. + * Uses VS Code CSS variables for native theming. + */ + +import * as vscode from 'vscode'; +import { KNOWN_CAPABILITIES, ARCHITECTURE_OPTIONS, DEVICE_FAMILY_OPTIONS } from './manifest-types'; +import { getEditorStyles, getErrorPageStyles } from './webview-styles'; +import { getEditorScript } from './webview-script'; + +function buildCapabilityCheckboxList( + capabilities: Array<{ name: string; label: string; namespace?: string }>, + prefix: string +): string { + return capabilities.map(c => { + const ns = c.namespace || prefix; + const capKey = ns ? `${ns}:${c.name}` : c.name; + return ``; + }).join(''); +} + +/** Builds HTML for custom dropdown option divs (used instead of native +
Unique identifier for your package in reverse-domain style (e.g. com.company.app), used internally by Windows and the Store
+
+ +
+ + +
X.500 distinguished name that identifies the publisher (e.g. CN=Contoso, O=Contoso Ltd), must match the subject name of your code-signing certificate
+
+
+
+ + +
Version of your application, revision (last segment) must be 0 for Store submissions
+
+
+
+ +
+ +
+ ${archOptionItems} +
+
+
CPU architecture this package targets
+
+
+
+ +
+ + +
+
Optional string used to differentiate packages that are part of a resource bundle or bundle optional packages (max 30 chars, alphanumeric/period/dash only)
+
+
+ + + + + + +
+
Package Properties
+

Use this section to configure the user-facing display information for your package. These values appear in the Microsoft Store listing, package details, and the Windows shell. Learn more

+
+ + +
Package name shown in Settings (Installed apps), the Microsoft Store, and other system surfaces, max 256 characters
+
+
+
+ + +
Publisher name shown in Settings (Installed apps), the Microsoft Store, and package details, max 256 characters
+
+
+
+ + +
Short summary of your package used in Store listings and package details, max 2048 characters (Optional)
+
+
+
+
+
+ +
+ + +
+
Package-relative path or key in resources.pri for the image displayed in the Microsoft Store and app installer
+
+
+
+ +
+
+
+
+ +
Package Type
+

Use this section to control what type of package this is. Most packages are Application packages. Learn more

+
+ +
+ +
+ ${buildSelectOptions([ + { value: 'application', label: 'Application (default)', selected: true }, + { value: 'framework', label: 'Framework' }, + { value: 'resource', label: 'Resource' }, + { value: 'modification', label: 'Modification' }, + ])} +
+
+
Application packages contain executable code and UI. Framework packages provide shared runtime libraries. Resource packages contain only language/scale assets. Modification packages customize a main package.
+
+ +
Advanced Properties
+

Use this section to configure optional advanced package properties such as user scope, automatic updates, integrity enforcement, and update behavior.

+
+ +
+ +
+ ${buildSelectOptions([ + { value: '', label: '(omit)', selected: true }, + { value: 'multiple', label: 'multiple' }, + { value: 'single', label: 'single' }, + ])} +
+
+
Whether the app supports multiple user sessions or only a single user
+
+
+ +
+ +
+ ${buildSelectOptions([ + { value: '', label: '(omit)', selected: true }, + { value: 'true', label: 'true' }, + { value: 'false', label: 'false' }, + ])} +
+
+
Whether executables in the package can be launched (set to false for content-only packages)
+
+
+ +
+ +
+ ${buildSelectOptions([ + { value: '', label: '(omit)', selected: true }, + { value: 'true', label: 'true' }, + { value: 'false', label: 'false' }, + ])} +
+
+
Whether the package allows content outside its install directory to be treated as package content
+
+
+ +
+ +
+ ${buildSelectOptions([ + { value: '', label: '(omit)', selected: true }, + { value: 'enabled', label: 'enabled' }, + { value: 'disabled', label: 'disabled' }, + ])} +
+
+
Controls whether file system write operations are virtualized or written to the real file system
+
+
+ +
+ +
+ ${buildSelectOptions([ + { value: '', label: '(omit)', selected: true }, + { value: 'enabled', label: 'enabled' }, + { value: 'disabled', label: 'disabled' }, + ])} +
+
+
Controls whether registry write operations are virtualized or written to the real registry
+
+
Update & Integrity
+

Use this section to configure automatic update behavior and content integrity enforcement for your package.

+
+
+ +
+ + +
+
URI to an .appinstaller file that enables automatic updates for sideloaded apps
+
+
+
+ +
+
+ +
+ ${buildSelectOptions([ + { value: 'on', label: 'on', selected: true }, + { value: 'off', label: 'off' }, + { value: 'default', label: 'default' }, + ])} +
+
+ +
+
Controls whether Windows enforces content integrity checks for the package — "on", "off", or "default"
+
+
+
+ +
+
+ +
+ ${buildSelectOptions([ + { value: 'allow', label: 'allow', selected: true }, + { value: 'defer', label: 'defer' }, + ])} +
+
+ +
+
Whether the package can be updated while it is running — "allow" applies updates immediately, "defer" waits until the app closes
+
+
+
+ + + +
+
+
+ + +
+
Target Device Families
+

Use this section to declare the Windows versions and framework packages your package requires. Target device families determine which devices can install your package. Learn more

+
+
+ +
+ ${DEVICE_FAMILY_OPTIONS.map(f => `
${f}
`).join('')} +
+
+ +
Package Dependencies
+

Use this section to declare framework and library package dependencies required by your package. Learn more

+
+ + +
Main Package Dependencies (uap3)
+

Use this section to declare a dependency on a main package for optional packages. Learn more

+
+ + +
Driver Constraints (uap5)
+

Use this section to declare driver constraints that your package depends on. Learn more

+
+ + +
OS Package Dependencies (uap7)
+

Use this section to declare a dependency on an OS package. Learn more

+
+ + +
Host Runtime Dependencies (uap10)
+

Use this section to declare a dependency on a host runtime. Learn more

+
+ + +
External Dependencies (win32dependencies)
+

Use this section to declare a dependency on an external Win32 component. Learn more

+
+ +
+ + +
+
Resources
+

Use this section to declare the language resources your package supports. Learn more

+
+ +
+ + +
+
Applications
+

Use this section to configure the entry points and visual presentation of your applications. Each Application element represents a separate executable that can be launched from the package. Learn more

+
+ +
+ + +
+
Capabilities
+

Use this section to declare the system resources and devices your package needs access to. Users will be prompted to grant restricted capabilities at install time. Only request capabilities your package actually uses. Learn more

+
+
+
+
General
+
${generalCaps}
+
+
+
Restricted (rescap)
+
${restrictedCaps}
+
+
+
Device
+
${deviceCaps}
+
+
+
Custom Capability
+

Custom capabilities must follow the format company.capabilityname_publisherId where publisherId is a 13-character base32 identifier. Learn more

+
+ + +
+ +
+
+
+
+
+
+
Hover over a capability to see its description.
+
+
+
+
+ +
+ + This editor does not support all appxmanifest customizations. For advanced scenarios, open the XML source. Missing a feature? File feedback. +
+ + +${getEditorScript(nonce, manifestDirUri)} + +`; +} \ No newline at end of file diff --git a/src/winapp-VSC/src/manifest-editor/webview-script-applications.ts b/src/winapp-VSC/src/manifest-editor/webview-script-applications.ts new file mode 100644 index 00000000..fc3d8339 --- /dev/null +++ b/src/winapp-VSC/src/manifest-editor/webview-script-applications.ts @@ -0,0 +1,654 @@ +/** + * Applications chunk for the AppxManifest editor webview script. + * Returns raw JavaScript to be concatenated into the webview IIFE. + */ +export function getApplicationsScript(): string { + return ` + // ─── Add application ──────────────────────────────── + document.getElementById('add-application-btn').addEventListener('click', () => { + vscode.postMessage({ type: 'addApplication' }); + }); + + function buildOptionalAssetsHtml(app, idx) { + let html = ''; + optionalVisualAssets.forEach(asset => { + const val = app.visualElements[asset.field]; + if (val !== null) { + html += '
' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
' + escapeHtml(asset.description) + '
' + + '
' + + '
'; + } + }); + return html; + } + + function buildAddVisualAssetMenuHtml(app, idx) { + let html = ''; + optionalVisualAssets.forEach(asset => { + const val = app.visualElements[asset.field]; + if (val === null || val === undefined) { + html += '
' + escapeHtml(asset.label) + '
'; + } + }); + return html; + } + + function hasUnspecifiedVisualAssets(app) { + return optionalVisualAssets.some(asset => app.visualElements[asset.field] === null || app.visualElements[asset.field] === undefined); + } + + function buildShowNameOnTilesHtml(app, idx) { + // Only show checkboxes for tile sizes that have defined visual assets + const ve = app.visualElements; + const availableTiles = showNameOnTilesOptions.filter(opt => { + // square150x150Logo is always required, so always a string + if (opt.veField === 'square150x150Logo') return true; + // Optional tiles: only show checkbox if the asset is defined (not null) + return ve[opt.veField] !== null; + }); + if (availableTiles.length === 0) return ''; + + const currentTiles = ve.showNameOnTiles || []; + let html = '
' + + '' + + '
Select which tile sizes display the app name overlay.
' + + '
'; + availableTiles.forEach(opt => { + const checked = currentTiles.includes(opt.tile) ? ' checked' : ''; + html += ''; + }); + html += '
'; + return html; + } + + function renderApplications(apps) { + const container = document.getElementById('applications-list'); + container.innerHTML = ''; + apps.forEach((app, idx) => { + const card = document.createElement('div'); + card.className = 'app-card'; + + const activeTab = activeAppSubTabs[idx] || 'info'; + + // Build extensions HTML + let extListHtml = ''; + if (app.extensions && app.extensions.length > 0) { + app.extensions.forEach((extXml, eidx) => { + const fields = parseExtensionFields(extXml); + let fieldsHtml = fields.map(f => { + let descHtml = f.description ? '
' + escapeHtml(f.description) + '
' : ''; + const textContentAttr = f.isTextContent ? ' data-ext-text-content="true"' : ''; + const errorClass = ''; + const errorMsg = '
'; + if (!f.editable) { + return '
' + + '' + + descHtml + '
'; + } + // Add a browse button for Registration fields + const isBrowsable = f.isTextContent && f.label === 'Registration'; + const inputHtml = ''; + if (isBrowsable) { + return '
' + + '
' + inputHtml + + '' + + '
' + descHtml + errorMsg + '
'; + } + return '
' + + inputHtml + descHtml + errorMsg + '
'; + }).join(''); + extListHtml += '
Extension #' + (eidx + 1) + '
' + fieldsHtml + '
'; + }); + } + + // Build add extension dropdown + let addExtDropdown = '
' + + '' + + '
'; + extensionTemplates.forEach(t => { + addExtDropdown += '
' + escapeHtml(t.label) + '
'; + }); + addExtDropdown += '
'; + + card.innerHTML = \` +
+ Application: \${escapeHtml(app.id || '(unnamed)')} + \${apps.length > 1 ? '' : ''} +
+
+ + + +
+
+

Configure the core identity and entry point of this application. Learn more

+
+ + +
Unique identifier used internally by Windows for activation
+
+
+
+ +
+ + +
+
Relative path to the .exe file inside the package
+
+
+
+ + +
Activation type or runtime class, use 'Windows.FullTrustApplication' for desktop (Win32) apps
+
+
+
Advanced Attributes
+

Optional advanced attributes for this application entry. These control trust level, runtime behavior, and multi-instance support.

+
+
+ +
+
+ +
+
appContainer
+
mediumIL
+
+
+ +
+
App trust level — appContainer (sandboxed UWP) or mediumIL (classic desktop, requires runFullTrust capability)
+
+
+
+ +
+
+ +
+
windowsApp
+
packagedClassicApp
+
win32App
+
+
+ +
+
Runtime model — windowsApp (UWP), packagedClassicApp (packaged desktop), or win32App (unpackaged desktop)
+
+
+
+ +
+
+ +
+
true
+
false
+
+
+ +
+
Whether multiple instances of this app can run simultaneously
+
+
+
+ +
+ + +
+
Command-line parameters passed to the executable at launch
+
+
+
+ + + + +
+
+
+
+

Extensions register your app for system integration points like URI protocols, file type associations, COM servers, and execution aliases. Learn more

+ \${extListHtml} + \${addExtDropdown} +
+
+

Visual assets define how your app appears in the Start menu, taskbar, and task switcher. Provide high-quality images at the correct sizes for a polished look. Learn more

+
+ + +
Name displayed on the app tile in the Start menu and in search results, max 256 characters
+
+
+
+ + +
Short description shown in package tooltips and accessibility tools, max 2048 characters
+
+
+
+ +
+ + +
+
Background color for the app tile, use a hex color or 'transparent'
+
+
+ +
+
+
+ +
+ + +
+
Medium tile image shown in the Start menu — package-relative path or key in resources.pri
+
+
+
+ +
+ + +
+
Small app icon shown in the taskbar, task switcher, and notification area — package-relative path or key
+
+
+
+
+ +
+
+
+
+ \${buildOptionalAssetsHtml(app, idx)} +
+ \${hasUnspecifiedVisualAssets(app) ? '
' + + '' + + '
' + + buildAddVisualAssetMenuHtml(app, idx) + + '
' : ''} + \${buildShowNameOnTilesHtml(app, idx)} +
Additional Visual Properties
+
+
+ +
+
+ +
+
default
+
none
+
+
+ +
+
Whether the app appears in the All Apps list — "default" shows it, "none" hides it (e.g. for background tasks)
+
+
+
+ +
+ + +
+
Abbreviated name shown on the app tile when space is limited (1–40 characters, on uap:DefaultTile)
+
+
+
+ +
+
+ + +
+ +
+
Background color for the splash screen, displayed behind the SplashScreen image
+
+
+
+ +
+
+ +
+
badge
+
badgeAndTileText
+
+
+ +
+
Lock screen notification style — "badge" (icon only) or "badgeAndTileText" (icon + text). Requires BadgeLogo and lock screen capability.
+
+
+
+ + + + +
+
+
+ \`; + container.appendChild(card); + + // Toggle optional fields visibility in this app card + toggleOptionalField('app-' + idx + '-trustlevel-group', 'add-app-' + idx + '-trustlevel', app.trustLevel); + toggleOptionalField('app-' + idx + '-runtimebehavior-group', 'add-app-' + idx + '-runtimebehavior', app.runtimeBehavior); + toggleOptionalField('app-' + idx + '-multiinstance-group', 'add-app-' + idx + '-multiinstance', app.supportsMultipleInstances); + toggleOptionalField('app-' + idx + '-parameters-group', 'add-app-' + idx + '-parameters', app.parameters); + toggleOptionalField('app-' + idx + '-applistentry-group', 'add-app-' + idx + '-applistentry', app.visualElements.appListEntry); + toggleOptionalField('app-' + idx + '-shortname-group', 'add-app-' + idx + '-shortname', app.visualElements.shortName); + toggleOptionalField('app-' + idx + '-splashbgcolor-group', 'add-app-' + idx + '-splashbgcolor', app.visualElements.splashScreenBackgroundColor); + toggleOptionalField('app-' + idx + '-lockscreennotif-group', 'add-app-' + idx + '-lockscreennotif', app.visualElements.lockScreenNotification); + + // Bind sub-tab switching + card.querySelectorAll('.app-sub-tab').forEach(tab => { + tab.addEventListener('click', () => { + const subtab = tab.getAttribute('data-subtab'); + const appIdx = tab.getAttribute('data-app-idx'); + activeAppSubTabs[appIdx] = subtab; + card.querySelectorAll('.app-sub-tab').forEach(t => t.classList.remove('active')); + card.querySelectorAll('.app-sub-content').forEach(c => c.classList.remove('active')); + tab.classList.add('active'); + card.querySelector('.app-sub-content[data-subcontent="' + subtab + '"]').classList.add('active'); + }); + }); + + // Bind remove application button + const removeAppBtn = card.querySelector('.remove-app-btn'); + if (removeAppBtn) { + removeAppBtn.addEventListener('click', () => { + vscode.postMessage({ type: 'removeApplication', index: parseInt(removeAppBtn.getAttribute('data-app-index'), 10) }); + }); + } + + // Bind field events + card.querySelectorAll('input[data-section]').forEach(inp => { + if (inp.type === 'color') { + inp.addEventListener('input', () => { + const textInput = card.querySelector('input[type="text"][data-field-name="' + inp.getAttribute('data-field-name') + '"]'); + if (textInput) textInput.value = inp.value; + debouncedFieldChange(inp); + }); + } else { + inp.addEventListener('input', () => debouncedFieldChange(inp)); + } + }); + initCustomSelects(card); + + // Bind extension remove buttons + card.querySelectorAll('.remove-ext').forEach(btn => { + btn.addEventListener('click', () => { + vscode.postMessage({ + type: 'removeExtension', + appIndex: parseInt(btn.getAttribute('data-app-index'), 10), + extIndex: parseInt(btn.getAttribute('data-ext-index'), 10) + }); + }); + }); + + // Bind editable extension field inputs + card.querySelectorAll('input[data-ext-field]').forEach(inp => { + let extDebounce = null; + inp.addEventListener('input', () => { + clearTimeout(extDebounce); + extDebounce = setTimeout(() => { + vscode.postMessage({ + type: 'updateExtensionField', + appIndex: parseInt(inp.getAttribute('data-app-index'), 10), + extIndex: parseInt(inp.getAttribute('data-ext-index'), 10), + fieldPath: inp.getAttribute('data-ext-field'), + value: inp.value, + isTextContent: inp.hasAttribute('data-ext-text-content') + }); + }, 300); + }); + }); + + // Bind browse file buttons + card.querySelectorAll('.browse-file-btn').forEach(btn => { + btn.addEventListener('click', () => { + vscode.postMessage({ + type: 'browseFile', + appIndex: parseInt(btn.getAttribute('data-app-index'), 10), + extIndex: parseInt(btn.getAttribute('data-ext-index'), 10), + fieldPath: btn.getAttribute('data-ext-field') + }); + }); + }); + + // Bind image browse buttons (dynamic in app cards) + card.querySelectorAll('.browse-image-btn').forEach(btn => { + btn.addEventListener('click', () => { + const msg = { + type: 'browseImage', + section: btn.getAttribute('data-section'), + field: btn.getAttribute('data-field-name'), + }; + const bIdx = btn.getAttribute('data-index'); + if (bIdx !== null) { msg.index = parseInt(bIdx, 10); } + vscode.postMessage(msg); + }); + }); + + // Bind exe browse buttons (dynamic in app cards) + card.querySelectorAll('.browse-exe-btn').forEach(btn => { + btn.addEventListener('click', () => { + const msg = { + type: 'browseExe', + section: btn.getAttribute('data-section'), + field: btn.getAttribute('data-field-name'), + }; + const bIdx = btn.getAttribute('data-index'); + if (bIdx !== null) { msg.index = parseInt(bIdx, 10); } + vscode.postMessage(msg); + }); + }); + + // Bind add extension dropdown + const addExtBtn = card.querySelector('.add-ext-btn'); + const addExtMenu = card.querySelector('.add-ext-menu'); + if (addExtBtn && addExtMenu) { + addExtBtn.addEventListener('click', (e) => { + e.stopPropagation(); + addExtMenu.classList.toggle('open'); + }); + card.querySelectorAll('.add-ext-item').forEach(item => { + item.addEventListener('click', () => { + vscode.postMessage({ + type: 'addExtension', + index: parseInt(item.getAttribute('data-app-index'), 10), + xml: item.getAttribute('data-xml') + }); + addExtMenu.classList.remove('open'); + }); + }); + } + + // Bind optional visual asset inputs and browse buttons + card.querySelectorAll('.optional-assets-list input[data-section]').forEach(inp => { + inp.addEventListener('input', () => debouncedFieldChange(inp)); + }); + card.querySelectorAll('.optional-assets-list .browse-image-btn').forEach(btn => { + btn.addEventListener('click', () => { + const msg = { + type: 'browseImage', + section: btn.getAttribute('data-section'), + field: btn.getAttribute('data-field-name'), + }; + const bIdx = btn.getAttribute('data-index'); + if (bIdx !== null) { msg.index = parseInt(bIdx, 10); } + vscode.postMessage(msg); + }); + }); + card.querySelectorAll('.optional-assets-list .btn-remove-field').forEach(btn => { + btn.addEventListener('click', () => { + const field = btn.getAttribute('data-field-name'); + const rIdx = parseInt(btn.getAttribute('data-index'), 10); + vscode.postMessage({ type: 'removeVisualAsset', field: field, index: rIdx }); + }); + }); + + // Bind add visual asset dropdown + const addVisualBtn = card.querySelector('.add-visual-asset-btn'); + const addVisualMenu = card.querySelector('.add-visual-asset-menu'); + if (addVisualBtn && addVisualMenu) { + addVisualBtn.addEventListener('click', (e) => { + e.stopPropagation(); + addVisualMenu.classList.toggle('open'); + }); + card.querySelectorAll('.add-visual-asset-item').forEach(item => { + item.addEventListener('click', () => { + const appIndex = parseInt(item.getAttribute('data-app-index'), 10); + const assetField = item.getAttribute('data-asset-field'); + const asset = optionalVisualAssets.find(a => a.field === assetField); + if (asset) { + vscode.postMessage({ + type: 'fieldChanged', + section: 'applications', + field: 'visualElements.' + assetField, + value: '', + index: appIndex + }); + } + addVisualMenu.classList.remove('open'); + }); + }); + } + + // Bind ShowNameOnTiles checkboxes + card.querySelectorAll('.show-name-tile-cb').forEach(cb => { + cb.addEventListener('change', () => { + const appIdx = parseInt(cb.getAttribute('data-app-index'), 10); + // Gather all checked tiles for this app + const tiles = []; + card.querySelectorAll('.show-name-tile-cb:checked').forEach(checked => { + tiles.push(checked.getAttribute('data-tile')); + }); + vscode.postMessage({ type: 'setShowNameOnTiles', appIndex: appIdx, tiles: tiles }); + }); + }); + + // Update logo previews + const logoPreview = card.querySelector('.app-logo-preview'); + const logoCaption = card.querySelector('.app-logo-caption'); + updateLogoPreview(logoPreview, app.visualElements.square150x150Logo, logoCaption); + + // Check image path warnings for all visual asset fields + card.querySelectorAll('.form-group[data-field*="visualElements."]').forEach(fg => { + const input = fg.querySelector('input[data-field-name]'); + if (input) { + const fieldName = input.getAttribute('data-field-name'); + const fieldIdx = parseInt(input.getAttribute('data-index'), 10); + checkImagePathWarning(fg, input.value, fieldName, isNaN(fieldIdx) ? undefined : fieldIdx); + } + }); + + // Regenerate Assets button + const updateAssetsBtn = card.querySelector('.update-assets-btn'); + if (updateAssetsBtn) { + updateAssetsBtn.addEventListener('click', () => { + vscode.postMessage({ type: 'updateAssets' }); + }); + } + }); + } + + function parseExtensionFields(xml) { + const parser = new DOMParser(); + const doc = parser.parseFromString(xml, 'application/xml'); + const root = doc.documentElement; + if (!root) return [{ label: 'Raw XML', value: xml, editable: false, description: '' }]; + + // Descriptions for known extension fields + const fieldDescriptions = { + // MCP Server / App Extension (windows.appExtension) + 'AppExtension.Name': 'Extension contract name, use "com.microsoft.windows.ai.mcpServer" to register as an MCP server', + 'AppExtension.Id': 'Unique identifier for this app extension instance', + 'AppExtension.DisplayName': 'Display name shown when discovering this extension', + 'AppExtension.PublicFolder': 'Folder in the package accessible to the host app, typically "Assets" or "Public"', + 'Registration': 'Path to the MCP server configuration JSON file, relative to the PublicFolder', + // COM Server (windows.comServer) + 'ExeServer.Executable': 'Relative path to the COM server executable inside the package', + 'ExeServer.DisplayName': 'Name for this COM server, shown in system tools and diagnostics', + 'Class.Id': 'CLSID (GUID) that uniquely identifies this COM class, format: {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}', + // App Execution Alias (windows.appExecutionAlias) + 'ExecutionAlias.Alias': 'Command-line alias users type to launch your app (e.g., "myapp.exe"). Must end in .exe', + // Background Tasks (windows.backgroundTasks) + 'Extension.EntryPoint': 'Activatable class ID for the background task (e.g., "MyApp.BackgroundTask"), or "Windows.FullTrustApplication" for Win32 apps', + 'Task.Type': 'Background task trigger type (e.g., "timer", "pushNotification", "systemEvent", "general")', + // Protocol Activation (windows.protocol) + 'Protocol.Name': 'URI scheme this app handles (e.g., "myapp"). Users launch your app with myapp://. Lowercase letters, digits, and ".", "+", "-" only', + // File Type Association (windows.fileTypeAssociation) + 'FileTypeAssociation.Name': 'Internal name for this file type association (letters, digits, periods only)', + 'DisplayName': 'User-friendly display name shown in the Open With dialog', + 'FileType': 'File extension to associate (must start with ".", e.g., ".txt", ".myext")', + // Startup Task (windows.startupTask) + 'StartupTask.TaskId': 'Unique identifier for this startup task, used to enable/disable it programmatically', + 'StartupTask.Enabled': 'Whether the task runs automatically at user logon ("true" or "false")', + 'StartupTask.DisplayName': 'Name shown to the user in Task Manager Startup tab', + // Share Target (windows.shareTarget) + 'DataFormat': 'Data format this share target accepts (e.g., "Text", "URI", "Bitmap", "Html", "StorageItems")', + // App Service (windows.appService) + 'AppService.Name': 'Unique name for this app service that other apps use to connect (e.g., "com.contoso.myservice")', + // Toast Notification Activation (windows.toastNotificationActivation) + 'ToastNotificationActivation.ToastActivatorCLSID': 'COM CLSID for toast activation, format: {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}', + }; + + const fields = []; + const category = root.getAttribute('Category'); + if (category) fields.push({ label: 'Category', value: category, editable: false, description: 'Extension category type' }); + function walk(el, depth) { + for (let i = 0; i < el.attributes.length; i++) { + const attr = el.attributes[i]; + if (attr.name === 'Category' && el === root) continue; + if (attr.name.startsWith('xmlns')) continue; + const fieldKey = (el.localName || el.nodeName) + '.' + attr.name; + const desc = fieldDescriptions[fieldKey] || ''; + fields.push({ label: fieldKey, value: attr.value, editable: true, description: desc }); + } + // Check for text-content elements (leaf elements with only text children) + let hasElementChildren = false; + let textContent = ''; + const children = el.childNodes; + for (let j = 0; j < children.length; j++) { + if (children[j].nodeType === 1) { hasElementChildren = true; } + else if (children[j].nodeType === 3) { textContent += children[j].nodeValue || ''; } + } + if (!hasElementChildren && textContent.trim()) { + const elName = el.localName || el.nodeName; + const desc = fieldDescriptions[elName] || ''; + fields.push({ label: elName, value: textContent.trim(), editable: true, description: desc, isTextContent: true }); + } else if (!hasElementChildren && el !== root) { + // Empty leaf element (ignoring xmlns attrs) — show as editable blank field + let nonXmlnsAttrs = 0; + for (let k = 0; k < el.attributes.length; k++) { + if (!el.attributes[k].name.startsWith('xmlns')) nonXmlnsAttrs++; + } + if (nonXmlnsAttrs > 0) return; // has real attributes, already handled above + const elName = el.localName || el.nodeName; + const desc = fieldDescriptions[elName] || ''; + fields.push({ label: elName, value: '', editable: true, description: desc, isTextContent: true }); + } + for (let j = 0; j < children.length; j++) { + if (children[j].nodeType === 1) walk(children[j], depth + 1); + } + } + walk(root, 0); + return fields; + } +`; +} diff --git a/src/winapp-VSC/src/manifest-editor/webview-script-capabilities.ts b/src/winapp-VSC/src/manifest-editor/webview-script-capabilities.ts new file mode 100644 index 00000000..45a81ae1 --- /dev/null +++ b/src/winapp-VSC/src/manifest-editor/webview-script-capabilities.ts @@ -0,0 +1,108 @@ +/** + * Capabilities chunk for the AppxManifest editor webview script. + * Returns raw JavaScript to be concatenated into the webview IIFE. + */ +export function getCapabilitiesScript(): string { + return ` + // ─── Capability toggles ───────────────────────────── + document.querySelectorAll('.cap-item input[type="checkbox"]').forEach(cb => { + cb.addEventListener('change', () => { + const cap = cb.getAttribute('data-capability'); + if (cb.checked) { + vscode.postMessage({ type: 'addCapability', capability: cap }); + } else { + vscode.postMessage({ type: 'removeCapability', capability: cap }); + } + }); + }); + + // Custom capability + document.getElementById('add-custom-cap').addEventListener('click', () => { + const input = document.getElementById('custom-cap-input'); + const errorEl = document.getElementById('custom-cap-error'); + const cap = input.value.trim(); + if (!cap) { + errorEl.textContent = 'Custom capability name is required.'; + errorEl.style.display = 'block'; + return; + } + // Validate format: company.capabilityname_publisherId (13-char base32) + const customCapRegex = /^[a-zA-Z0-9]+(\\.[a-zA-Z0-9]+)+_[a-z0-9]{13}$/; + if (!customCapRegex.test(cap)) { + errorEl.textContent = 'Custom capability must follow the format company.capabilityname_publisherId (e.g. Contoso.Devices.SerialCommunication_0wer1ey63g7b4).'; + errorEl.style.display = 'block'; + return; + } + errorEl.style.display = 'none'; + vscode.postMessage({ type: 'addCapability', capability: cap }); + input.value = ''; + }); + document.getElementById('custom-cap-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + document.getElementById('add-custom-cap').click(); + } + }); + document.getElementById('custom-cap-input').addEventListener('input', () => { + document.getElementById('custom-cap-error').style.display = 'none'; + }); + + // ─── Capability hover descriptions ────────────────── + document.querySelectorAll('.cap-item').forEach(item => { + item.addEventListener('mouseenter', () => { + const cap = item.getAttribute('data-cap') || ''; + const rawName = cap.replace(/^(rescap:|device:)/, ''); + const desc = capabilityDescriptions[rawName] || 'No description available.'; + const nameEl = document.getElementById('cap-description-name'); + const textEl = document.getElementById('cap-description-text'); + if (nameEl) nameEl.textContent = item.querySelector('span')?.textContent || rawName; + if (textEl) textEl.textContent = desc; + }); + }); + + function updateCapabilityCheckboxes(capabilities) { + const capContainer = document.getElementById('tab-capabilities'); + // Uncheck all first (scoped to capabilities tab only) + capContainer.querySelectorAll('.cap-item input[type="checkbox"]').forEach(cb => { + cb.checked = false; + }); + + // Check matching known capabilities + const knownCapNames = new Set(); + capContainer.querySelectorAll('.cap-item input[type="checkbox"]').forEach(cb => { + const cap = cb.getAttribute('data-capability'); + knownCapNames.add(cap); + if (capabilities.includes(cap)) { + cb.checked = true; + } + }); + + // Render custom capabilities (not in known list) + const customCaps = capabilities.filter(c => !knownCapNames.has(c)); + const customList = document.getElementById('custom-caps-list'); + customList.innerHTML = ''; + const customCapRegex = /^[a-zA-Z0-9]+(\\.[a-zA-Z0-9]+)+_[a-z0-9]{13}$/; + customCaps.forEach(cap => { + const wrapper = document.createElement('div'); + wrapper.className = 'custom-cap-entry'; + const label = document.createElement('label'); + label.className = 'cap-item'; + label.innerHTML = \`\${escapeHtml(cap)}\`; + wrapper.appendChild(label); + if (!customCapRegex.test(cap)) { + const errSpan = document.createElement('span'); + errSpan.className = 'validation-msg error'; + errSpan.textContent = 'Invalid format. Expected: company.capabilityname_publisherId (e.g. Contoso.Devices.SerialCommunication_0wer1ey63g7b4)'; + errSpan.style.display = 'block'; + errSpan.style.marginLeft = '24px'; + wrapper.appendChild(errSpan); + } + customList.appendChild(wrapper); + label.querySelector('input').addEventListener('change', (e) => { + if (!e.target.checked) { + vscode.postMessage({ type: 'removeCapability', capability: cap }); + } + }); + }); + } +`; +} diff --git a/src/winapp-VSC/src/manifest-editor/webview-script-dependencies.ts b/src/winapp-VSC/src/manifest-editor/webview-script-dependencies.ts new file mode 100644 index 00000000..62262d52 --- /dev/null +++ b/src/winapp-VSC/src/manifest-editor/webview-script-dependencies.ts @@ -0,0 +1,274 @@ +/** + * Dependencies chunk for the AppxManifest editor webview script. + * Returns raw JavaScript to be concatenated into the webview IIFE. + */ +export function getDependenciesScript(): string { + return ` + // ─── Add/Remove target device family (dropdown) ───── + document.getElementById('add-target-family').addEventListener('click', (e) => { + e.stopPropagation(); + document.getElementById('add-family-menu').classList.toggle('open'); + }); + document.querySelectorAll('#add-family-menu .custom-dropdown-item').forEach(item => { + item.addEventListener('click', () => { + const name = item.getAttribute('data-family'); + vscode.postMessage({ + type: 'addTargetDeviceFamily', + family: { name, minVersion: '', maxVersionTested: '' } + }); + document.getElementById('add-family-menu').classList.remove('open'); + }); + }); + document.addEventListener('click', () => { + document.getElementById('add-family-menu').classList.remove('open'); + document.querySelectorAll('.add-ext-menu').forEach(m => m.classList.remove('open')); + document.querySelectorAll('.add-visual-asset-menu').forEach(m => m.classList.remove('open')); + }); + + // ─── Add/Remove package dependency ────────────────── + document.getElementById('add-package-dep').addEventListener('click', () => { + vscode.postMessage({ + type: 'addPackageDependency', + dependency: { name: '', minVersion: '', publisher: '', optional: '' } + }); + }); + + document.getElementById('add-main-pkg-dep').addEventListener('click', () => { + vscode.postMessage({ type: 'addMainPackageDependency', dependency: { name: '' } }); + }); + document.getElementById('add-driver-constraint').addEventListener('click', () => { + vscode.postMessage({ type: 'addDriverConstraint', constraint: { name: '', minVersion: '', minDate: '' } }); + }); + document.getElementById('add-os-pkg-dep').addEventListener('click', () => { + vscode.postMessage({ type: 'addOSPackageDependency', dependency: { name: '', version: '' } }); + }); + document.getElementById('add-host-runtime-dep').addEventListener('click', () => { + vscode.postMessage({ type: 'addHostRuntimeDependency', dependency: { name: '', publisher: '', minVersion: '' } }); + }); + document.getElementById('add-external-dep').addEventListener('click', () => { + vscode.postMessage({ type: 'addExternalDependency', dependency: { name: '', publisher: '', minVersion: '', optional: '' } }); + }); + + function renderReorderableList(containerId, items, config) { + const container = document.getElementById(containerId); + container.innerHTML = ''; + items.forEach((item, idx) => { + const div = document.createElement('div'); + div.className = 'list-item'; + div.innerHTML = + '
' + + '' + config.titleFn(item, idx) + '' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + config.fieldsFn(item, idx); + container.appendChild(div); + + div.querySelectorAll('input[data-section]').forEach(inp => { + inp.addEventListener('input', () => debouncedFieldChange(inp)); + }); + if (config.hasCustomSelects) { + initCustomSelects(div); + } + div.querySelector('.remove-item').addEventListener('click', () => { + vscode.postMessage({ type: config.removeType, index: idx }); + }); + div.querySelector('.move-up').addEventListener('click', () => { + vscode.postMessage({ type: config.moveType, index: idx, direction: 'up' }); + }); + div.querySelector('.move-down').addEventListener('click', () => { + vscode.postMessage({ type: config.moveType, index: idx, direction: 'down' }); + }); + }); + } + + function renderTargetDeviceFamilies(families) { + renderReorderableList('target-device-families', families, { + titleFn: (fam) => 'Target Device: ' + escapeHtml(fam.name), + removeType: 'removeTargetDeviceFamily', + moveType: 'moveTargetDeviceFamily', + fieldsFn: (fam, idx) => \` +
+ + +
Minimum Windows version required to install this package
+
+
+
+ + +
Highest Windows version app has tested against, must be ≥ Min Version, used to determine compatibility behavior
+
+
\`, + }); + } + + function renderPackageDependencies(deps) { + renderReorderableList('package-dependencies', deps, { + titleFn: () => 'Name:', + removeType: 'removePackageDependency', + moveType: 'movePackageDependency', + hasCustomSelects: true, + fieldsFn: (dep, idx) => \` +
+ +
Package identity name
+
+
+
+ + +
Minimum version required
+
+
+
+ + +
X.500 distinguished name of the package publisher
+
+
+
+ +
+ +
+
(omit)
+
true
+
false
+
+
+
Whether this dependency is optional (requires uap6 namespace)
+
+
\`, + }); + } + + function renderMainPackageDependencies(deps) { + renderReorderableList('main-package-dependencies', deps, { + titleFn: () => 'Name:', + removeType: 'removeMainPackageDependency', + moveType: 'moveMainPackageDependency', + fieldsFn: (dep, idx) => \` +
+ +
Package identity name of the main package
+
+
\`, + }); + } + + function renderDriverConstraints(constraints) { + renderReorderableList('driver-constraints', constraints, { + titleFn: () => 'Name:', + removeType: 'removeDriverConstraint', + moveType: 'moveDriverConstraint', + fieldsFn: (dc, idx) => \` +
+ +
The driver package identity name that this constraint applies to
+
+
+
+ + +
Minimum driver version required, in dotted-quad format (e.g. 1.0.0.0)
+
+
+
+ + +
Earliest driver date accepted, in YYYY-MM-DD format
+
+
\`, + }); + } + + function renderOSPackageDependencies(deps) { + renderReorderableList('os-package-dependencies', deps, { + titleFn: () => 'Name:', + removeType: 'removeOSPackageDependency', + moveType: 'moveOSPackageDependency', + fieldsFn: (dep, idx) => \` +
+ +
Package identity name of the OS package
+
+
+
+ + +
DotQuad version number (e.g. 10.0.0.0), each part 0–65535
+
+
\`, + }); + } + + function renderHostRuntimeDependencies(deps) { + renderReorderableList('host-runtime-dependencies', deps, { + titleFn: () => 'Name:', + removeType: 'removeHostRuntimeDependency', + moveType: 'moveHostRuntimeDependency', + fieldsFn: (dep, idx) => \` +
+ +
Package identity name of the host runtime
+
+
+
+ + +
X.500 distinguished name of the host runtime publisher
+
+
+
+ + +
Minimum DotQuad version required (e.g. 1.0.0.0), each part 0–65535
+
+
\`, + }); + } + + function renderExternalDependencies(deps) { + renderReorderableList('external-dependencies', deps, { + titleFn: () => 'Name:', + removeType: 'removeExternalDependency', + moveType: 'moveExternalDependency', + hasCustomSelects: true, + fieldsFn: (dep, idx) => \` +
+ +
Name of the external Win32 component
+
+
+
+ + +
X.500 distinguished name of the external component publisher
+
+
+
+ + +
Minimum version required for the external component
+
+
+
+ +
+ +
+
(omit)
+
true
+
false
+
+
+
Whether this external dependency is optional
+
\`, + }); + } +`; +} diff --git a/src/winapp-VSC/src/manifest-editor/webview-script-identity.ts b/src/winapp-VSC/src/manifest-editor/webview-script-identity.ts new file mode 100644 index 00000000..070f61c6 --- /dev/null +++ b/src/winapp-VSC/src/manifest-editor/webview-script-identity.ts @@ -0,0 +1,90 @@ +/** + * Identity chunk for the AppxManifest editor webview script. + * Returns raw JavaScript to be concatenated into the webview IIFE. + */ +export function getIdentityScript(): string { + return ` + // ─── Phone Identity Add/Remove buttons ───────────── + document.getElementById('add-phone-identity-btn')?.addEventListener('click', () => { + vscode.postMessage({ type: 'addPhoneIdentity' }); + }); + document.getElementById('remove-phone-identity-btn')?.addEventListener('click', () => { + vscode.postMessage({ type: 'removePhoneIdentity' }); + }); + + // ─── Optional field Add/Remove buttons ───────────── + document.addEventListener('click', (e) => { + const addBtn = e.target.closest('.btn-add-field'); + if (addBtn) { + const targetId = addBtn.getAttribute('data-target'); + const group = document.getElementById(targetId); + if (group) { + group.classList.remove('hidden-optional'); + addBtn.classList.add('hidden-optional'); + userOpenedOptionalFields.add(targetId); + // Set default value and trigger change + const defaultVal = addBtn.getAttribute('data-default') || ''; + const input = group.querySelector('input[data-section]'); + const csTrigger = group.querySelector('.custom-select-trigger[data-section]'); + if (csTrigger) { + // For custom selects, set value via setCustomSelectValue using the wrapper's id + const wrapper = csTrigger.closest('.custom-select'); + if (wrapper && wrapper.id) { + setCustomSelectValue(wrapper.id, defaultVal); + } + csTrigger.focus(); + // Trigger immediate field change for custom selects + const section = csTrigger.getAttribute('data-section'); + const field = csTrigger.getAttribute('data-field-name'); + const index = parseInt(csTrigger.getAttribute('data-index') || '0', 10); + vscode.postMessage({ type: 'fieldChanged', section, field, value: defaultVal, index }); + } else if (input) { + input.value = defaultVal; + input.focus(); + if (defaultVal) { + // Immediately send the default value to the extension + const section = input.getAttribute('data-section'); + const field = input.getAttribute('data-field-name'); + const index = input.getAttribute('data-index'); + if (section && field) { + const msg = { type: 'fieldChanged', section, field, value: defaultVal }; + if (index !== null) { msg.index = parseInt(index, 10); } + vscode.postMessage(msg); + } + } else if (input.tagName === 'INPUT') { + const fieldAttr = group.getAttribute('data-field') || ''; + const errText = fieldAttr === 'identity.resourceId' + ? 'Resource ID must be at least 1 character.' + : 'This field is required. Enter a value or remove the field.'; + setGroupValidation(group, 'error', errText); + } + } + } + return; + } + + const removeBtn = e.target.closest('.btn-remove-field'); + if (removeBtn) { + const targetId = removeBtn.getAttribute('data-target'); + const group = document.getElementById(targetId); + if (group) { + group.classList.add('hidden-optional'); + userOpenedOptionalFields.delete(targetId); + // Find the corresponding add button + const addBtnForGroup = document.querySelector('.btn-add-field[data-target="' + targetId + '"]'); + if (addBtnForGroup) addBtnForGroup.classList.remove('hidden-optional'); + // Send empty value to remove the attribute + const section = removeBtn.getAttribute('data-section'); + const fieldName = removeBtn.getAttribute('data-field-name'); + const index = removeBtn.getAttribute('data-index'); + if (section && fieldName) { + const msg = { type: 'fieldChanged', section: section, field: fieldName, value: '' }; + if (index !== null) { msg.index = parseInt(index, 10); } + vscode.postMessage(msg); + } + } + return; + } + }); +`; +} diff --git a/src/winapp-VSC/src/manifest-editor/webview-script-resources.ts b/src/winapp-VSC/src/manifest-editor/webview-script-resources.ts new file mode 100644 index 00000000..6cff7942 --- /dev/null +++ b/src/winapp-VSC/src/manifest-editor/webview-script-resources.ts @@ -0,0 +1,64 @@ +/** + * Resources chunk for the AppxManifest editor webview script. + * Returns raw JavaScript to be concatenated into the webview IIFE. + */ +export function getResourcesScript(): string { + return ` + // ─── Add resource ─────────────────────────────────── + document.getElementById('add-resource-btn').addEventListener('click', () => { + vscode.postMessage({ + type: 'addResource', + resource: { language: '', scale: '', dxFeatureLevel: '' } + }); + }); + + function renderResources(resources) { + const scaleOptions = ['', '80', '100', '120', '125', '140', '150', '160', '175', '180', '200', '225', '250', '300', '350', '400', '450']; + const dxOptions = ['', 'dx9', 'dx10', 'dx11', 'dx12']; + renderReorderableList('resources-list', resources, { + titleFn: () => 'Language:', + removeType: 'removeResource', + moveType: 'moveResource', + hasCustomSelects: true, + fieldsFn: (res, idx) => { + const scaleOptionsHtml = scaleOptions.map(s => + '
' + (s || '(none)') + '
' + ).join(''); + const dxOptionsHtml = dxOptions.map(d => + '
' + (d || '(none)') + '
' + ).join(''); + const scaleLabel = res.scale || '(none)'; + const dxLabel = res.dxFeatureLevel || '(none)'; + return \` +
+ +
BCP-47 language tag (e.g. "en-us", "fr-fr", "ja-jp") or "x-generate"
+
+
+
+ +
+ +
+ \${scaleOptionsHtml} +
+
+
Resolution scale for resource selection (e.g. 100, 200, 400)
+
+
+
+ +
+ +
+ \${dxOptionsHtml} +
+
+
DirectX feature level for resource selection
+
+
\`; + }, + }); + } +`; +} diff --git a/src/winapp-VSC/src/manifest-editor/webview-script.ts b/src/winapp-VSC/src/manifest-editor/webview-script.ts new file mode 100644 index 00000000..642538f1 --- /dev/null +++ b/src/winapp-VSC/src/manifest-editor/webview-script.ts @@ -0,0 +1,699 @@ +/** + * Client-side JavaScript for the AppxManifest editor webview. + * Extracted from webview-content.ts for maintainability. + */ + +import { CAPABILITY_DESCRIPTIONS, EXTENSION_TEMPLATES, OPTIONAL_VISUAL_ASSETS, SHOW_NAME_ON_TILES_OPTIONS } from './manifest-types'; +import { getIdentityScript } from './webview-script-identity'; +import { getCapabilitiesScript } from './webview-script-capabilities'; +import { getDependenciesScript } from './webview-script-dependencies'; +import { getApplicationsScript } from './webview-script-applications'; +import { getResourcesScript } from './webview-script-resources'; + +export function getEditorScript(nonce: string, manifestDirUri: string): string { + const safeManifestDirUri = JSON.stringify(manifestDirUri); + return ` `; +} diff --git a/src/winapp-VSC/src/manifest-editor/webview-styles.ts b/src/winapp-VSC/src/manifest-editor/webview-styles.ts new file mode 100644 index 00000000..5bcbd9e1 --- /dev/null +++ b/src/winapp-VSC/src/manifest-editor/webview-styles.ts @@ -0,0 +1,591 @@ +/** + * CSS styles for the AppxManifest editor webview. + * Extracted from webview-content.ts for maintainability. + */ + +export function getEditorStyles(nonce: string): string { + return ` `; +} + +/** CSS styles for the parse-error page shown when the manifest XML is invalid. */ +export function getErrorPageStyles(nonce: string): string { + return ` `; +} diff --git a/src/winapp-VSC/src/manifest-editor/xml-utils.ts b/src/winapp-VSC/src/manifest-editor/xml-utils.ts new file mode 100644 index 00000000..3fce2e46 --- /dev/null +++ b/src/winapp-VSC/src/manifest-editor/xml-utils.ts @@ -0,0 +1,422 @@ +/** + * Low-level XML string manipulation utilities for the manifest editor. + */ + +// Common AppxManifest namespace URIs +export const NS = { + default: 'http://schemas.microsoft.com/appx/manifest/foundation/windows10', + uap: 'http://schemas.microsoft.com/appx/manifest/uap/windows10', + uap3: 'http://schemas.microsoft.com/appx/manifest/uap/windows10/3', + uap5: 'http://schemas.microsoft.com/appx/manifest/uap/windows10/5', + uap7: 'http://schemas.microsoft.com/appx/manifest/uap/windows10/7', + uap10: 'http://schemas.microsoft.com/appx/manifest/uap/windows10/10', + rescap: 'http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities', + desktop: 'http://schemas.microsoft.com/appx/manifest/desktop/windows10', + win32dependencies: 'http://schemas.microsoft.com/appx/manifest/win32dependencies/windows10', + systemai: 'http://schemas.microsoft.com/appx/manifest/systemai/windows10', +}; + +/** Namespace URIs for capability prefixes. */ +export const CAPABILITY_NS_URIS: Record = { + uap: NS.uap, + uap2: 'http://schemas.microsoft.com/appx/manifest/uap/windows10/2', + uap3: NS.uap3, + uap4: 'http://schemas.microsoft.com/appx/manifest/uap/windows10/4', + uap5: NS.uap5, + uap6: 'http://schemas.microsoft.com/appx/manifest/uap/windows10/6', + uap7: NS.uap7, + rescap: NS.rescap, + iot: 'http://schemas.microsoft.com/appx/manifest/iot/windows10', + systemai: NS.systemai, +}; + +/** Escape special regex characters in a string. */ +export function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Escape XML-special characters for use in attribute values. */ +export function escapeXmlAttr(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +export function escapeXmlText(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +/** Replace an XML attribute value in-place. Returns the original string if not found. */ +export function replaceAttribute(xml: string, elementPattern: RegExp, attrName: string, newValue: string): string | null { + const escaped = escapeXmlAttr(newValue); + // Find the element in the XML + const elementMatch = elementPattern.exec(xml); + if (!elementMatch) { return null; } + + // Within the matched element, find and replace the attribute value + const elementStr = elementMatch[0]; + const attrRegex = new RegExp(`(${escapeRegex(attrName)}\\s*=\\s*)(["'])((?:(?!\\2).)*?)\\2`); + const attrMatch = attrRegex.exec(elementStr); + if (!attrMatch) { return null; } + + const newElementStr = elementStr.substring(0, attrMatch.index) + + attrMatch[1] + attrMatch[2] + escaped + attrMatch[2] + + elementStr.substring(attrMatch.index + attrMatch[0].length); + + return xml.substring(0, elementMatch.index) + newElementStr + xml.substring(elementMatch.index + elementStr.length); +} + +/** Remove an XML attribute from an element in-place. Returns the original string if not found. */ +export function removeAttribute(xml: string, elementPattern: RegExp, attrName: string): string { + const elementMatch = elementPattern.exec(xml); + if (!elementMatch) { return xml; } + + const elementStr = elementMatch[0]; + // Match the attribute with surrounding whitespace (consume leading space) + const attrRegex = new RegExp(`\\s+${escapeRegex(attrName)}\\s*=\\s*(["'])(?:(?!\\1).)*?\\1`); + const attrMatch = attrRegex.exec(elementStr); + if (!attrMatch) { return xml; } + + const newElementStr = elementStr.substring(0, attrMatch.index) + elementStr.substring(attrMatch.index + attrMatch[0].length); + return xml.substring(0, elementMatch.index) + newElementStr + xml.substring(elementMatch.index + elementStr.length); +} + +/** Add a new attribute to an existing XML element. Returns the original string if element not found. */ +export function addAttributeToElement(xml: string, elementPattern: RegExp, attrName: string, value: string): string { + const escaped = escapeXmlAttr(value); + const elementMatch = elementPattern.exec(xml); + if (!elementMatch) { return xml; } + + const elementStr = elementMatch[0]; + // Insert the new attribute before the closing /> or > + const closingMatch = /(\s*\/?>)\s*$/.exec(elementStr); + if (!closingMatch) { return xml; } + + const insertPos = closingMatch.index; + + // Detect if element is multi-line; if so, match existing attribute indentation + const attrIndentMatch = /\n([ \t]+)\w/.exec(elementStr); + let attrText: string; + if (attrIndentMatch) { + // Multi-line element — put new attribute on its own line with same indent + attrText = '\n' + attrIndentMatch[1] + `${attrName}="${escaped}"`; + } else { + // Single-line element — append with a space + attrText = ` ${attrName}="${escaped}"`; + } + + const newElementStr = elementStr.substring(0, insertPos) + attrText + elementStr.substring(insertPos); + return xml.substring(0, elementMatch.index) + newElementStr + xml.substring(elementMatch.index + elementStr.length); +} + +/** Replace the text content of an XML element in-place. Returns the original string if not found. */ +export function replaceElementText(xml: string, tagPattern: RegExp, newValue: string): string { + const match = tagPattern.exec(xml); + if (!match) { return xml; } + + // Escape XML-special characters in text content + const escaped = newValue.replace(/&/g, '&').replace(//g, '>'); + // match[0] is the full match including tags, match[1] is the opening tag, match[2] is the old text + return xml.substring(0, match.index) + match[1] + escaped + match[3] + xml.substring(match.index + match[0].length); +} + +/** Find the bounds of a parent element by local name (handles optional namespace prefix). */ +export function findParentBounds(xml: string, localName: string): { openStart: number; contentStart: number; contentEnd: number; closeEnd: number } | null { + const openPattern = new RegExp(`<(?:[a-zA-Z0-9]+:)?${escapeRegex(localName)}\\b`); + const openMatch = openPattern.exec(xml); + if (!openMatch) { return null; } + const openStart = openMatch.index; + const gt = xml.indexOf('>', openStart); + if (gt === -1) { return null; } + if (xml[gt - 1] === '/') { return null; } // self-closing + const contentStart = gt + 1; + // Find matching close tag + const closePattern = new RegExp(``); + const closeMatch = closePattern.exec(xml.substring(contentStart)); + if (!closeMatch) { return null; } + const contentEnd = contentStart + closeMatch.index; + const closeEnd = contentEnd + closeMatch[0].length; + return { openStart, contentStart, contentEnd, closeEnd }; +} + +/** Find the start/end positions of direct child elements within an XML region. */ +export function findDirectChildElementBounds(xml: string, regionStart: number, regionEnd: number): Array<{start: number; end: number}> { + const elements: Array<{start: number; end: number}> = []; + let pos = regionStart; + + while (pos < regionEnd) { + const lt = xml.indexOf('<', pos); + if (lt === -1 || lt >= regionEnd) { break; } + + // Skip comments + if (xml[lt + 1] === '!' && xml[lt + 2] === '-' && xml[lt + 3] === '-') { + const commentEnd = xml.indexOf('-->', lt); + if (commentEnd === -1) { break; } + pos = commentEnd + 3; + continue; + } + + // Skip CDATA sections + if (xml[lt + 1] === '!' && xml[lt + 2] === '[' && xml.startsWith('CDATA[', lt + 3)) { + const cdataEnd = xml.indexOf(']]>', lt); + if (cdataEnd === -1) { break; } + pos = cdataEnd + 3; + continue; + } + + // Skip closing tags (parent's close tag or unexpected) + if (xml[lt + 1] === '/') { break; } + + // Skip processing instructions + if (xml[lt + 1] === '?') { + const piEnd = xml.indexOf('?>', lt); + if (piEnd === -1) { break; } + pos = piEnd + 2; + continue; + } + + // This is an element opening tag + const elemStart = lt; + const gt = xml.indexOf('>', lt); + if (gt === -1) { break; } + + if (xml[gt - 1] === '/') { + // Self-closing element + elements.push({ start: elemStart, end: gt + 1 }); + pos = gt + 1; + continue; + } + + // Non-self-closing — track depth to find matching close + let depth = 1; + pos = gt + 1; + while (pos < xml.length && depth > 0) { + const nextLt = xml.indexOf('<', pos); + if (nextLt === -1) { break; } + + if (xml[nextLt + 1] === '!' && xml[nextLt + 2] === '-' && xml[nextLt + 3] === '-') { + const ce = xml.indexOf('-->', nextLt); + if (ce === -1) { break; } + pos = ce + 3; + continue; + } + + // Skip CDATA sections inside depth tracking + if (xml[nextLt + 1] === '!' && xml[nextLt + 2] === '[' && xml.startsWith('CDATA[', nextLt + 3)) { + const ce = xml.indexOf(']]>', nextLt); + if (ce === -1) { break; } + pos = ce + 3; + continue; + } + + if (xml[nextLt + 1] === '/') { + depth--; + const closeGt = xml.indexOf('>', nextLt); + if (closeGt === -1) { break; } + pos = closeGt + 1; + if (depth === 0) { + elements.push({ start: elemStart, end: closeGt + 1 }); + } + } else { + const openGt = xml.indexOf('>', nextLt); + if (openGt === -1) { break; } + if (xml[openGt - 1] === '/') { + // Self-closing nested element, doesn't change depth + pos = openGt + 1; + } else { + depth++; + pos = openGt + 1; + } + } + } + } + + return elements; +} + +/** Ensure a namespace declaration is present on the Package element. */ +export function ensureNamespace(xmlText: string, prefix: string, uri: string): string { + const decl = `xmlns:${prefix}="${uri}"`; + // Check for both double-quoted and single-quoted declarations + if (xmlText.includes(`xmlns:${prefix}="${uri}"`) || xmlText.includes(`xmlns:${prefix}='${uri}'`)) { + return xmlText; + } + + // Find the full opening tag (may span multiple lines) + const pkgMatch = /]*>/s.exec(xmlText); + if (!pkgMatch) { + // Fallback: try partial match for malformed XML + const partialMatch = / of the Package tag + // Find the last xmlns attribute line to insert after it + const indent = xmlnsLineMatch[1]; + // Find the position right before the last attribute or the closing > + // Strategy: insert before IgnorableNamespaces if present, otherwise before > + const ignorablePos = pkgTag.indexOf('IgnorableNamespaces='); + if (ignorablePos > 0) { + // Find start of that line + let lineStart = ignorablePos; + while (lineStart > 0 && pkgTag[lineStart - 1] !== '\n') { lineStart--; } + const insertPos = pkgStart + lineStart; + return xmlText.substring(0, insertPos) + indent + decl + '\n' + xmlText.substring(insertPos); + } + // No IgnorableNamespaces — insert before the closing > + const closePos = pkgStart + pkgTag.length - 1; // position of > + // Check if there's whitespace/newline before > + let beforeClose = closePos - 1; + while (beforeClose > pkgStart && (xmlText[beforeClose] === ' ' || xmlText[beforeClose] === '\t')) { beforeClose--; } + if (xmlText[beforeClose] === '\n' || xmlText[beforeClose] === '\r') { + // > is on its own or at end of line — insert before it on a new line + return xmlText.substring(0, closePos) + indent + decl + '\n' + xmlText.substring(closePos); + } + // Insert on a new line before > + return xmlText.substring(0, closePos) + '\n' + indent + decl + xmlText.substring(closePos); + } + + // No multiline xmlns pattern detected — Package tag is single-line + // Insert after " 0 && xmlText[pkgLineStart - 1] !== '\n') { pkgLineStart--; } + const pkgIndent = xmlText.substring(pkgLineStart, pkgStart); + // Use one extra level of indent for attributes (tab or two spaces based on file) + const tabInFile = xmlText.includes('\t'); + const attrIndent = pkgIndent + (tabInFile ? '\t' : ' '); + + // If there are existing inline xmlns declarations, put the new one after the last one on a new line + const lastXmlnsInTag = /.*xmlns[^"]*"[^"]*"/s.exec(pkgTag); + if (lastXmlnsInTag) { + const afterLastXmlns = pkgStart + lastXmlnsInTag[0].length; + return xmlText.substring(0, afterLastXmlns) + '\n' + attrIndent + decl + xmlText.substring(afterLastXmlns); + } + + // Fallback: simple inline insert — use substring splicing to avoid CodeQL + // false positive on .replace() with angle-bracket patterns (js/incomplete-multi-character-sanitization) + const pkgFallbackMatch = / tag if the prefix + * is no longer used anywhere else in the document body. + * Also removes the prefix from IgnorableNamespaces if present. + */ +export function removeNamespaceIfUnused(xmlText: string, prefix: string): string { + // Check if the prefix is still used anywhere in the document (e.g., ) for any usage of this prefix + const pkgMatch = /]*>/s.exec(xmlText); + if (!pkgMatch) { return xmlText; } + const bodyStart = pkgMatch.index + pkgMatch[0].length; + const body = xmlText.substring(bodyStart); + if (usagePattern.test(body)) { + return xmlText; // prefix still in use + } + + // Remove the xmlns:prefix="..." declaration from the Package tag + let result = xmlText; + const pkgTag = pkgMatch[0]; + const declPattern = new RegExp(`\\s*xmlns:${prefix}=["'][^"']*["']`); + const declMatch = declPattern.exec(pkgTag); + if (!declMatch) { return xmlText; } + + const declStart = pkgMatch.index + declMatch.index; + const declEnd = declStart + declMatch[0].length; + result = result.substring(0, declStart) + result.substring(declEnd); + + // Also remove prefix from IgnorableNamespaces attribute + const ignorablePattern = /IgnorableNamespaces=["']([^"']*)["']/; + const ignorableMatch = ignorablePattern.exec(result); + if (ignorableMatch) { + const namespaces = ignorableMatch[1].split(/\s+/).filter(ns => ns !== prefix); + if (namespaces.length === 0) { + // Remove the entire IgnorableNamespaces attribute + const attrPattern = new RegExp(`\\s*IgnorableNamespaces=["'][^"']*["']`); + result = result.replace(attrPattern, ''); + } else { + const newAttr = `IgnorableNamespaces="${namespaces.join(' ')}"`; + result = result.replace(ignorablePattern, newAttr); + } + } + + return result; +} +export function swapAdjacentElements(xmlText: string, a: { start: number; end: number }, b: { start: number; end: number }): string { + // a must come before b + const first = a.start < b.start ? a : b; + const second = a.start < b.start ? b : a; + const firstText = xmlText.substring(first.start, first.end); + const secondText = xmlText.substring(second.start, second.end); + return xmlText.substring(0, first.start) + secondText + xmlText.substring(first.end, second.start) + firstText + xmlText.substring(second.end); +} + +/** Find the start/end positions of the nth Application element. */ +export function findNthApplicationRegion(xml: string, index: number): { start: number; end: number } | null { + const bounds = findParentBounds(xml, 'Applications'); + if (!bounds) { return null; } + const children = findDirectChildElementBounds(xml, bounds.contentStart, bounds.contentEnd); + const apps = children.filter(c => /^= apps.length) { return null; } + return apps[index]; +} + +/** Detect the indentation of the line containing the given position. */ +export function detectIndent(xml: string, pos: number): string { + const lineStart = xml.lastIndexOf('\n', pos - 1); + if (lineStart === -1) { return ''; } + const lineContent = xml.substring(lineStart + 1, pos); + const match = /^(\s*)/.exec(lineContent); + return match ? match[1] : ''; +} + +/** Determine the element info for creating a capability XML element. */ +export function getCapabilityElementInfo(capability: string): { elementName: string; ns: string | null; attrName: string } { + if (capability.startsWith('device:')) { + return { elementName: 'DeviceCapability', ns: NS.default, attrName: capability.replace('device:', '') }; + } + const colonIdx = capability.indexOf(':'); + if (colonIdx > 0) { + const prefix = capability.substring(0, colonIdx); + const name = capability.substring(colonIdx + 1); + return { elementName: `${prefix}:Capability`, ns: null, attrName: name }; + } + // Custom capability: company.name_publisherId format → uap4:CustomCapability + if (/^[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)+_[a-z0-9]{13}$/.test(capability)) { + return { elementName: 'uap4:CustomCapability', ns: null, attrName: capability }; + } + return { elementName: 'Capability', ns: NS.default, attrName: capability }; +} + +/** Build the XML string for a new child element inside VisualElements. */ +export function buildVisualChildElement(veField: string, value: string): string | null { + const defaultTileFields: Record = { + wide310x150Logo: 'Wide310x150Logo', + square71x71Logo: 'Square71x71Logo', + square310x310Logo: 'Square310x310Logo', + }; + if (defaultTileFields[veField]) { + return ``; + } + if (veField === 'badgeLogo') { + return ``; + } + if (veField === 'splashScreenImage') { + return ``; + } + return null; +} diff --git a/src/winapp-VSC/src/test/e2e/README.md b/src/winapp-VSC/src/test/e2e/README.md new file mode 100644 index 00000000..d9b9c600 --- /dev/null +++ b/src/winapp-VSC/src/test/e2e/README.md @@ -0,0 +1,246 @@ +# Manifest Editor – End-to-End Tests + +Automated Playwright tests that launch VS Code as an Electron app, open an AppxManifest file in the custom WinApp editor, and interact with every tab and control in the webview UI. + +## Quick start + +```powershell +cd src/winapp-VSC + +# Install dependencies (first time only) +npm install + +# Run the full E2E suite +npm run test:e2e +``` + +> **Prerequisites** – VS Code must be installed at the default location and all other VS Code windows must be closed before running (Playwright needs exclusive access to the Electron process). + +## Architecture + +| File | Purpose | +|------|---------| +| `playwright.config.ts` | Playwright configuration – 60 s timeout, 1 retry, 1 worker, trace-on-failure | +| `helpers.ts` | Shared launch / teardown / interaction utilities (tab switching, field editing, validation checks, XML round-trip reads) | +| `shared-context.ts` | Singleton manager – launches one VS Code instance and reuses it across all specs via `ensureEditor()` and `resetManifest()` | +| `global-teardown.ts` | Calls `closeSharedEditor()` after the entire suite completes | + +### Fixture manifests + +Tests run against real AppxManifest files stored in `src/test/fixtures/`: + +| Fixture | Used by | +|---------|---------| +| `winui-gallery.appxmanifest` | Default – used by the 7 core tab specs and the parse-error spec | +| `push-notifications-sample.appxmanifest` | `push-notifications-fixture.spec.ts` | +| `background-task-sample.appxmanifest` | `background-task-fixture.spec.ts` | +| `widgets-sample.appxmanifest` | Unit tests for nested Capabilities and package-level Extensions | +| `edge-cases.appxmanifest` | Unit tests for edge-case / adversarial parsing | + +`resetManifest(ctx, fixtureName?)` copies the selected fixture into the workspace before each spec file, waits for the editor to reload, and re-acquires the webview frame. + +--- + +## Test inventory (125 tests) + +### `editor-launch.spec.ts` — 11 tests + +Validates that the custom editor launches correctly, all tabs render, and global UI elements are present. + +| # | Test | Validates | +|---|------|-----------| +| 1 | manifest editor opens and shows tab bar | Custom editor webview loads and the tab bar is visible | +| 2 | Identity tab is active by default | Identity is selected and its panel is shown on open | +| 3 | all six tabs are visible | Identity, Properties, Dependencies, Resources, Applications, and Capabilities tabs exist | +| 4 | can switch to Properties tab | Tab switching to Properties works and hides Identity | +| 5 | can switch to Dependencies tab | Tab switching to Dependencies works | +| 6 | can switch to Resources tab | Tab switching to Resources works | +| 7 | can switch to Applications tab | Tab switching to Applications works | +| 8 | can switch to Capabilities tab | Tab switching to Capabilities works | +| 9 | can switch back to Identity tab | Returning to Identity works and it becomes selected | +| 10 | View XML button is visible | View XML button exists with the expected label | +| 11 | info banner with feedback link is visible | Info banner and GitHub feedback link are present | + +### `identity-tab.spec.ts` — 18 tests + +Validates all Identity tab fields, validation rules, processor architecture, ResourceID, and PhoneIdentity. + +| # | Test | Validates | +|---|------|-----------| +| 1 | name field is populated from manifest | Manifest Name loads into the Identity Name field | +| 2 | publisher field is populated from manifest | Publisher loads into the Identity Publisher field | +| 3 | version field is populated from manifest | Version loads into the Identity Version field | +| 4 | editing name field updates the XML document | Name edits persist to `Name="..."` in XML | +| 5 | editing version field updates the XML document | Version edits persist to XML | +| 6 | clearing name shows validation error | Empty name triggers validation/error styling | +| 7 | entering valid name clears validation error | Valid name removes the error state | +| 8 | invalid version format shows validation error | Bad version format is rejected | +| 9 | valid version clears error | Valid version removes the error state | +| 10 | processor architecture custom select displays current value | Processor architecture select renders a current value | +| 11 | can change processor architecture | Selecting x64 writes `ProcessorArchitecture="x64"` to XML | +| 12 | add Resource ID button is visible | Optional Resource ID add button is present | +| 13 | clicking Add Resource ID shows the field | Clicking Add Resource ID reveals the field group | +| 14 | entering a Resource ID updates the XML | ResourceId edits persist to XML | +| 15 | Phone Identity section is visible | PhoneIdentity section appears when present in the fixture | +| 16 | Phone Identity fields are populated | Phone Product ID and Publisher ID populate from the manifest | +| 17 | editing Phone Identity updates the XML | Phone Product ID edits persist to XML | +| 18 | remove Phone Identity button removes the section | Removing PhoneIdentity deletes it from XML | + +### `properties-tab.spec.ts` — 10 tests + +Validates Properties tab fields, validation, logo browse button, and package type selector. + +| # | Test | Validates | +|---|------|-----------| +| 1 | display name field is populated | DisplayName loads from the manifest | +| 2 | publisher display name is populated | PublisherDisplayName loads from the manifest | +| 3 | logo path is populated | Logo path loads from the manifest | +| 4 | editing display name updates the XML | DisplayName edits persist to XML | +| 5 | editing publisher display name updates the XML | PublisherDisplayName edits persist to XML | +| 6 | editing logo path updates the XML | Logo edits persist to XML | +| 7 | clearing display name shows validation error | Empty DisplayName triggers validation | +| 8 | clearing publisher display name shows validation error | Empty PublisherDisplayName triggers validation | +| 9 | restoring values clears errors | Restoring both values clears error states | +| 10 | package type selector is visible | Package type dropdown is rendered | + +### `dependencies-tab.spec.ts` — 16 tests + +Validates target device families, package dependencies, and all dependency sub-types (add/edit/remove). + +| # | Test | Validates | +|---|------|-----------| +| 1 | shows existing target device family from fixture | Target device family entries are loaded | +| 2 | target device family fields are populated | Target family title contains Windows.Desktop | +| 3 | add target device family dropdown is visible | Add-family control is present | +| 4 | can add a target device family via dropdown | Adding a family increases the list count | +| 5 | can edit target device family minVersion | MinVersion edits persist to XML | +| 6 | can remove a target device family | Removing a family decreases the list count | +| 7 | add package dependency button is visible | Package dependency add control exists | +| 8 | can add a package dependency | Adding a dependency increases the list count | +| 9 | can edit package dependency name | Package dependency name edits persist to XML | +| 10 | can remove a package dependency | Removing a dependency decreases the list count | +| 11 | add main package dependency button is visible | Main package dependency add control exists | +| 12 | add driver constraint button is visible | Driver constraint add control exists | +| 13 | add OS package dependency button is visible | OS package dependency add control exists | +| 14 | add host runtime dependency button is visible | Host runtime dependency add control exists | +| 15 | add external dependency button is visible | External dependency add control exists | +| 16 | can add and remove a main package dependency | Main package dependency round-trip works | + +### `resources-tab.spec.ts` — 6 tests + +Validates resource list rendering, add/edit/move/remove operations. + +| # | Test | Validates | +|---|------|-----------| +| 1 | shows existing resources from fixture | Resources list is initially populated | +| 2 | resource language field is populated | First resource language loads as `x-generate` | +| 3 | add resource button is visible | Add resource button exists | +| 4 | can add a new resource | Clicking add increases the resource count | +| 5 | can edit resource language | Resource language edits persist to XML | +| 6 | can remove a resource | Removing a resource decreases the count | + +### `applications-tab.spec.ts` — 18 tests + +Validates application cards, sub-tabs (Info, Extensions, Visual Assets), extension CRUD, visual asset CRUD, and add/remove applications. + +| # | Test | Validates | +|---|------|-----------| +| 1 | shows at least one application card | At least one app card renders | +| 2 | application card has title | App card title is visible | +| 3 | app Info sub-tab is visible by default | Info is the default sub-tab | +| 4 | app id field is populated | App ID loads as `App` | +| 5 | app executable field is populated | Executable field is populated | +| 6 | editing app id updates the XML | App ID edits persist to XML | +| 7 | editing app executable updates the XML | Executable edits persist to XML | +| 8 | can switch to Extensions sub-tab | Extensions sub-tab becomes visible | +| 9 | existing extensions are shown | Extension items render if present | +| 10 | can add a Protocol Activation extension and fill its fields | Adding extension via dropdown works, filling Name field persists to XML | +| 11 | can remove the newly added extension | Removing an extension decreases the count | +| 12 | can switch to Visual Assets sub-tab | Visual Assets sub-tab becomes visible | +| 13 | visual asset fields are present | Visual asset inputs are rendered | +| 14 | can add a Splash Screen visual asset via dropdown | Adding optional Splash Screen asset via dropdown works and persists to XML | +| 15 | add application button is visible | Add application button exists | +| 16 | can add a new application | Adding an app increases card count | +| 17 | can remove the newly added application | Removing an app decreases card count | +| 18 | browse executable button is visible | Executable browse button exists | + +### `capabilities-tab.spec.ts` — 16 tests + +Validates all four capability categories, checkbox toggling, hover descriptions, and custom capability CRUD. + +| # | Test | Validates | +|---|------|-----------| +| 1 | General capabilities section is visible | General category renders | +| 2 | Restricted capabilities section is visible | Restricted category renders | +| 3 | Device capabilities section is visible | Device category renders | +| 4 | Custom Capability section is visible | Custom category renders | +| 5 | runFullTrust capability is checked (from fixture) | Restricted `runFullTrust` capability is preselected | +| 6 | can check internetClient capability | Toggling internetClient on updates UI and XML | +| 7 | can uncheck internetClient capability | Toggling internetClient off updates UI and XML | +| 8 | can toggle a device capability | Device capability can be checked and unchecked | +| 9 | description panel exists | Capability description panel is present | +| 10 | hovering a capability shows description | Hovering populates description text | +| 11 | custom capability input is visible | Custom capability input and add button are present | +| 12 | adding empty custom capability shows error | Empty submission shows a required error | +| 13 | adding invalid format custom capability shows error | Invalid format is rejected | +| 14 | typing in input clears validation error | Typing clears the custom capability error | +| 15 | adding valid custom capability succeeds | Valid custom capability is accepted and written to XML | +| 16 | custom capability appears in the custom capabilities list | New custom capability appears in the list | + +### `parse-error.spec.ts` — 1 test + +Validates error handling for malformed manifests. This spec launches its own VS Code instance with broken XML. + +| # | Test | Validates | +|---|------|-----------| +| 1 | shows error view for malformed XML | Malformed manifest XML opens the error view with expected message and "Open in Text Editor" action | + +### `push-notifications-fixture.spec.ts` — 8 tests + +Validates the editor against the push-notifications-sample fixture, covering identity, properties, dependencies, applications, and capabilities. + +| # | Test | Validates | +|---|------|-----------| +| 1 | identity fields are populated correctly | Identity fields load from the push notifications fixture | +| 2 | properties fields are populated correctly | Properties fields (DisplayName, PublisherDisplayName, Logo) load correctly | +| 3 | has one target device family (Windows.Universal) | Exactly one target device family exists and is Windows.Universal | +| 4 | application card is present with correct id | App card exists and its ID is `App` | +| 5 | application has extensions | Extension elements are present in the application | +| 6 | internetClient capability is checked | internetClient is enabled in the fixture | +| 7 | runFullTrust restricted capability is checked | Restricted `rescap:runFullTrust` is enabled in the fixture | +| 8 | editing identity name persists to XML | Editing the name writes back to XML | + +### `background-task-fixture.spec.ts` — 17 tests + +Validates the editor against the background-task-sample fixture, which exercises PhoneIdentity, multiple device families, all five dependency sub-types, empty resources, custom capabilities, and restricted capabilities. + +| # | Test | Validates | +|---|------|-----------| +| 1 | identity fields are populated correctly | Identity fields load from the background task fixture | +| 2 | PhoneIdentity section is visible | PhoneIdentity section is present | +| 3 | PhoneIdentity fields are populated | PhoneIdentity values are loaded correctly | +| 4 | properties fields are populated correctly | Properties fields (DisplayName, PublisherDisplayName, Logo) load correctly | +| 5 | has two target device families | Exactly two target device families exist | +| 6 | first device family is Windows.Universal | First target family is Windows.Universal | +| 7 | second device family is Windows.Desktop | Second target family is Windows.Desktop | +| 8 | main package dependencies are present | Main package dependencies exist | +| 9 | driver constraints are present | Driver constraints exist | +| 10 | OS package dependencies are present | OS package dependencies exist | +| 11 | host runtime dependencies are present | Host runtime dependencies exist | +| 12 | external dependencies are present | External dependencies exist | +| 13 | resources section has no items (empty Resources element) | Resources list is empty | +| 14 | application card is present | App card exists with ID `App` | +| 15 | runFullTrust restricted capability is checked | Restricted `rescap:runFullTrust` is enabled | +| 16 | custom capability is listed | Custom capabilities are rendered | +| 17 | editing display name persists to XML | DisplayName edits write back to XML | + +--- + +## Known flaky tests + +These tests occasionally fail on the first attempt but pass on retry (Playwright's built-in retry handles them): + +| Test | Reason | +|------|--------| +| `background-task-fixture.spec.ts` › identity fields are populated correctly | Timing — editor may not have fully reloaded after fixture swap | +| `parse-error.spec.ts` › shows error view for malformed XML | Launches its own VS Code instance — sensitive to startup timing | diff --git a/src/winapp-VSC/src/test/e2e/applications-tab.spec.ts b/src/winapp-VSC/src/test/e2e/applications-tab.spec.ts new file mode 100644 index 00000000..716a343b --- /dev/null +++ b/src/winapp-VSC/src/test/e2e/applications-tab.spec.ts @@ -0,0 +1,1065 @@ +/** + * E2E tests: Applications tab – app cards, sub-tabs, extensions, + * visual assets, add/remove applications. + */ + +import { test, expect, type FrameLocator } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + switchTab, + clickButton, + getInputValue, + setInputValue, + waitForDebounce, + readManifestXml, + switchAppSubTab, + type VSCodeTestContext, +} from './helpers'; +import { ensureEditor, resetManifest } from './shared-context'; + +let ctx: VSCodeTestContext; +let frame: FrameLocator; + +test.beforeAll(async () => { + const shared = await ensureEditor(); + ctx = shared.ctx; + frame = await resetManifest(ctx); + await switchTab(frame, 'applications'); +}); + +// ─── Application cards ────────────────────────────────── + +test('shows at least one application card', async () => { + const cards = await frame.locator('.app-card').count(); + expect(cards).toBeGreaterThanOrEqual(1); +}); + +test('application card has title', async () => { + const title = frame.locator('.app-card').first().locator('.app-card-title'); + await expect(title).toBeVisible(); +}); + +// ─── Info sub-tab ─────────────────────────────────────── + +test('app Info sub-tab is visible by default', async () => { + const card = frame.locator('.app-card').first(); + const infoTab = card.locator('.app-sub-tab').first(); + await expect(infoTab).toBeVisible(); +}); + +test('app id field is populated', async () => { + const card = frame.locator('.app-card').first(); + const idInput = card.locator('input[data-field-name="id"]'); + const val = await idInput.inputValue(); + expect(val).toBe('App'); +}); + +test('app executable field is populated', async () => { + const card = frame.locator('.app-card').first(); + const exeInput = card.locator('input[data-field-name="executable"]'); + const val = await exeInput.inputValue(); + expect(val).toContain('.exe'); +}); + +test('editing app id updates the XML', async () => { + const card = frame.locator('.app-card').first(); + const idInput = card.locator('input[data-field-name="id"]'); + await idInput.fill('MyApp'); + await idInput.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Id="MyApp"'); +}); + +test('editing app executable updates the XML', async () => { + const card = frame.locator('.app-card').first(); + const exeInput = card.locator('input[data-field-name="executable"]'); + await exeInput.fill('MyApp.exe'); + await exeInput.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Executable="MyApp.exe"'); +}); + +// ─── Extensions sub-tab ───────────────────────────────── + +test('can switch to Extensions sub-tab', async () => { + await switchAppSubTab(frame, 0, 'extensions'); + await ctx.page.waitForTimeout(500); + + const card = frame.locator('.app-card').first(); + const extensionsContent = card.locator('.app-sub-content[data-subcontent="extensions"]'); + // The extensions tab content should become active + await expect(extensionsContent).toBeVisible(); +}); + +test('existing extensions are shown', async () => { + const card = frame.locator('.app-card').first(); + // The WinUI Gallery fixture has extensions + const extItems = card.locator('.ext-item, [data-ext-index]'); + const count = await extItems.count(); + expect(count).toBeGreaterThanOrEqual(0); // May or may not have extensions rendered as separate items +}); + +test('can add a Protocol Activation extension and fill its fields', async () => { + const card = frame.locator('.app-card').first(); + const extContent = card.locator('.app-sub-content[data-subcontent="extensions"]'); + const initialCount = await extContent.locator('.list-item').count(); + + // Open the Add Extension dropdown and select Protocol Activation + await card.locator('.add-ext-btn').click(); + await ctx.page.waitForTimeout(300); + await card.locator('.add-ext-item:has-text("Protocol Activation")').click(); + await ctx.page.waitForTimeout(1_000); + + // Verify a new extension item was added + const updatedCount = await extContent.locator('.list-item').count(); + expect(updatedCount).toBe(initialCount + 1); + + // Fill in the Protocol Name field on the new extension + const newExt = extContent.locator('.list-item').last(); + const nameInput = newExt.locator('input[data-ext-field]').first(); + await nameInput.fill('myprotocol'); + await nameInput.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + // Verify the XML contains the protocol extension + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Name="myprotocol"'); + expect(xml).toContain('windows.protocol'); +}); + +test('can remove the newly added extension', async () => { + const card = frame.locator('.app-card').first(); + const extContent = card.locator('.app-sub-content[data-subcontent="extensions"]'); + const initialCount = await extContent.locator('.list-item').count(); + + // Remove the last extension (the one we just added) + const lastExt = extContent.locator('.list-item').last(); + await lastExt.locator('.remove-ext').click(); + await ctx.page.waitForTimeout(1_000); + + const updatedCount = await extContent.locator('.list-item').count(); + expect(updatedCount).toBe(initialCount - 1); +}); + +// ─── Visual Assets sub-tab ────────────────────────────── + +test('can switch to Visual Assets sub-tab', async () => { + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + const card = frame.locator('.app-card').first(); + const visualContent = card.locator('.app-sub-content[data-subcontent="visual"]'); + await expect(visualContent).toBeVisible(); +}); + +test('visual asset fields are present', async () => { + const card = frame.locator('.app-card').first(); + const visualContent = card.locator('.app-sub-content[data-subcontent="visual"]'); + + // Should have display name, description, and background color inputs + const inputs = visualContent.locator('input[data-section]'); + const count = await inputs.count(); + expect(count).toBeGreaterThanOrEqual(1); +}); + +test('can add a Splash Screen visual asset via dropdown', async () => { + const card = frame.locator('.app-card').first(); + const visualContent = card.locator('.app-sub-content[data-subcontent="visual"]'); + + // The Splash Screen field should not exist yet (optional asset) + const splashBefore = visualContent.locator('input[data-field-name="visualElements.splashScreenImage"]'); + const existsBefore = await splashBefore.count(); + + if (existsBefore === 0) { + // Open the Add Visual Asset dropdown and select Splash Screen + await card.locator('.add-visual-asset-btn').click(); + await ctx.page.waitForTimeout(300); + await card.locator('.add-visual-asset-item:has-text("Splash Screen")').click(); + await ctx.page.waitForTimeout(1_000); + + // Verify the Splash Screen field now appears + const splashAfter = visualContent.locator('input[data-field-name="visualElements.splashScreenImage"]'); + await expect(splashAfter).toBeVisible(); + + // Fill in a path and verify it persists to XML + await splashAfter.fill('Assets\\SplashScreen.png'); + await splashAfter.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('SplashScreen.png'); + } else { + // If already present, just verify it's visible + await expect(splashBefore.first()).toBeVisible(); + } +}); + +test('can add a Wide 310x150 Logo visual asset', async () => { + const card = frame.locator('.app-card').first(); + const addBtn = card.locator('.add-visual-asset-btn'); + if (await addBtn.count() > 0) { + await addBtn.click(); + await ctx.page.waitForTimeout(300); + const wideItem = card.locator('.add-visual-asset-item:has-text("Wide 310x150")'); + if (await wideItem.count() > 0) { + await wideItem.click(); + await ctx.page.waitForTimeout(1_000); + const input = card.locator('input[data-field-name="visualElements.wide310x150Logo"]'); + await expect(input).toBeVisible(); + await input.fill('Assets\\Wide310x150Logo.png'); + await input.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Wide310x150Logo.png'); + } + } +}); + +test('can add a Square 71x71 Logo visual asset', async () => { + const card = frame.locator('.app-card').first(); + const addBtn = card.locator('.add-visual-asset-btn'); + if (await addBtn.count() > 0) { + await addBtn.click(); + await ctx.page.waitForTimeout(300); + const item = card.locator('.add-visual-asset-item:has-text("Square 71x71")'); + if (await item.count() > 0) { + await item.click(); + await ctx.page.waitForTimeout(1_000); + const input = card.locator('input[data-field-name="visualElements.square71x71Logo"]'); + await expect(input).toBeVisible(); + await input.fill('Assets\\Square71x71Logo.png'); + await input.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Square71x71Logo.png'); + } + } +}); + +test('can add a Square 310x310 Logo visual asset', async () => { + const card = frame.locator('.app-card').first(); + const addBtn = card.locator('.add-visual-asset-btn'); + if (await addBtn.count() > 0) { + await addBtn.click(); + await ctx.page.waitForTimeout(300); + const item = card.locator('.add-visual-asset-item:has-text("Square 310x310")'); + if (await item.count() > 0) { + await item.click(); + await ctx.page.waitForTimeout(1_000); + const input = card.locator('input[data-field-name="visualElements.square310x310Logo"]'); + await expect(input).toBeVisible(); + await input.fill('Assets\\Square310x310Logo.png'); + await input.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Square310x310Logo.png'); + } + } +}); + +test('can add a Badge Logo visual asset', async () => { + const card = frame.locator('.app-card').first(); + // Ensure we're on visual sub-tab and dismiss any open menus + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + const addBtn = card.locator('.add-visual-asset-btn'); + if (await addBtn.count() > 0 && await addBtn.isVisible()) { + await addBtn.click(); + await ctx.page.waitForTimeout(300); + const item = card.locator('.add-visual-asset-item:has-text("Badge Logo")'); + if (await item.count() > 0) { + await item.click(); + await ctx.page.waitForTimeout(1_000); + const input = card.locator('input[data-field-name="visualElements.badgeLogo"]'); + await expect(input).toBeVisible(); + await input.fill('Assets\\BadgeLogo.png'); + await input.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('BadgeLogo.png'); + } + } +}); + +test('can add Wide 310x150 Logo visual asset', async () => { + const card = frame.locator('.app-card').first(); + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + const addBtn = card.locator('.add-visual-asset-btn'); + if (await addBtn.count() > 0 && await addBtn.isVisible()) { + await addBtn.click(); + await ctx.page.waitForTimeout(300); + const item = card.locator('.add-visual-asset-item:has-text("Wide 310x150 Logo")'); + if (await item.count() > 0) { + await item.click(); + await ctx.page.waitForTimeout(1_000); + const input = card.locator('input[data-field-name="visualElements.wide310x150Logo"]'); + await expect(input).toBeVisible(); + await input.fill('Assets\\Wide310x150Logo.png'); + await input.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Wide310x150Logo.png'); + expect(xml).toContain('DefaultTile'); + } + } +}); + +test('can add Square 71x71 Logo visual asset', async () => { + const card = frame.locator('.app-card').first(); + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + const addBtn = card.locator('.add-visual-asset-btn'); + if (await addBtn.count() > 0 && await addBtn.isVisible()) { + await addBtn.click(); + await ctx.page.waitForTimeout(300); + const item = card.locator('.add-visual-asset-item:has-text("Square 71x71 Logo")'); + if (await item.count() > 0) { + await item.click(); + await ctx.page.waitForTimeout(1_000); + const input = card.locator('input[data-field-name="visualElements.square71x71Logo"]'); + await expect(input).toBeVisible(); + await input.fill('Assets\\Square71x71Logo.png'); + await input.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Square71x71Logo.png'); + expect(xml).toContain('DefaultTile'); + } + } +}); + +test('can add Square 310x310 Logo visual asset', async () => { + const card = frame.locator('.app-card').first(); + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + const addBtn = card.locator('.add-visual-asset-btn'); + if (await addBtn.count() > 0 && await addBtn.isVisible()) { + await addBtn.click(); + await ctx.page.waitForTimeout(300); + const item = card.locator('.add-visual-asset-item:has-text("Square 310x310 Logo")'); + if (await item.count() > 0) { + await item.click(); + await ctx.page.waitForTimeout(1_000); + const input = card.locator('input[data-field-name="visualElements.square310x310Logo"]'); + await expect(input).toBeVisible(); + await input.fill('Assets\\Square310x310Logo.png'); + await input.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Square310x310Logo.png'); + expect(xml).toContain('DefaultTile'); + } + } +}); + +test('can remove Badge Logo visual asset', async () => { + const card = frame.locator('.app-card').first(); + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + const removeBtn = card.locator('.optional-assets-list .btn-remove-field[data-field-name="visualElements.badgeLogo"]'); + if (await removeBtn.count() > 0 && await removeBtn.isVisible()) { + await removeBtn.click(); + await ctx.page.waitForTimeout(1_500); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).not.toContain('BadgeLogo.png'); + expect(xml).not.toContain('LockScreen'); + + // The input should no longer be visible + const input = card.locator('input[data-field-name="visualElements.badgeLogo"]'); + await expect(input).not.toBeVisible(); + } +}); + +test('can remove Wide 310x150 Logo visual asset', async () => { + const card = frame.locator('.app-card').first(); + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + const removeBtn = card.locator('.optional-assets-list .btn-remove-field[data-field-name="visualElements.wide310x150Logo"]'); + if (await removeBtn.count() > 0 && await removeBtn.isVisible()) { + await removeBtn.click(); + await ctx.page.waitForTimeout(1_500); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).not.toContain('Wide310x150Logo'); + + const input = card.locator('input[data-field-name="visualElements.wide310x150Logo"]'); + await expect(input).not.toBeVisible(); + } +}); + +test('can remove Square 71x71 Logo visual asset', async () => { + const card = frame.locator('.app-card').first(); + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + const removeBtn = card.locator('.optional-assets-list .btn-remove-field[data-field-name="visualElements.square71x71Logo"]'); + if (await removeBtn.count() > 0 && await removeBtn.isVisible()) { + await removeBtn.click(); + await ctx.page.waitForTimeout(1_500); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).not.toContain('Square71x71Logo'); + + const input = card.locator('input[data-field-name="visualElements.square71x71Logo"]'); + await expect(input).not.toBeVisible(); + } +}); + +test('can remove Square 310x310 Logo visual asset', async () => { + const card = frame.locator('.app-card').first(); + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + const removeBtn = card.locator('.optional-assets-list .btn-remove-field[data-field-name="visualElements.square310x310Logo"]'); + if (await removeBtn.count() > 0 && await removeBtn.isVisible()) { + await removeBtn.click(); + await ctx.page.waitForTimeout(1_500); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).not.toContain('Square310x310Logo'); + + const input = card.locator('input[data-field-name="visualElements.square310x310Logo"]'); + await expect(input).not.toBeVisible(); + } +}); + +test('can add Splash Screen Background Color', async () => { + const card = frame.locator('.app-card').first(); + const addBtn = card.locator('#add-app-0-splashbgcolor'); + if (await addBtn.count() > 0 && await addBtn.isVisible()) { + await addBtn.click(); + await ctx.page.waitForTimeout(500); + + const group = card.locator('#app-0-splashbgcolor-group'); + await expect(group).toBeVisible(); + + // Fill in a color value via the text input + const textInput = group.locator('input[type="text"][data-field-name="visualElements.splashScreenBackgroundColor"]'); + await textInput.fill('#FF0000'); + await textInput.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('#FF0000'); + } +}); + +test('can remove Splash Screen Background Color', async () => { + const card = frame.locator('.app-card').first(); + const group = card.locator('#app-0-splashbgcolor-group'); + if (await group.isVisible()) { + const removeBtn = group.locator('.btn-remove-field'); + await removeBtn.click(); + await ctx.page.waitForTimeout(500); + + // The add button should reappear + await expect(card.locator('#add-app-0-splashbgcolor')).toBeVisible(); + } +}); + +test('Show Name on Tiles checkboxes are visible', async () => { + const card = frame.locator('.app-card').first(); + // Make sure we're on the Visual Assets sub-tab + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + const tileSection = card.locator('.show-name-on-tiles-section'); + if (await tileSection.count() > 0) { + await tileSection.scrollIntoViewIfNeeded(); + await expect(tileSection).toBeVisible(); + const checkboxes = tileSection.locator('.show-name-tile-cb'); + expect(await checkboxes.count()).toBeGreaterThanOrEqual(1); + } +}); + +test('can toggle Show Name on Medium tile', async () => { + const card = frame.locator('.app-card').first(); + const cb = card.locator('.show-name-tile-cb[data-tile="square150x150Logo"]'); + if (await cb.count() > 0) { + await cb.scrollIntoViewIfNeeded(); + const wasBefore = await cb.isChecked(); + await cb.click(); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + if (!wasBefore) { + expect(xml).toContain('square150x150Logo'); + } + // Toggle back + await cb.click(); + await ctx.page.waitForTimeout(500); + } +}); + +// ─── Advanced Attributes (Info sub-tab) ───────────────── + +test('can add Trust Level attribute', async () => { + await switchAppSubTab(frame, 0, 'info'); + await ctx.page.waitForTimeout(500); + + const card = frame.locator('.app-card').first(); + const addBtn = card.locator('#add-app-0-trustlevel'); + await expect(addBtn).toBeVisible(); + await addBtn.click(); + await ctx.page.waitForTimeout(500); + + const group = card.locator('#app-0-trustlevel-group'); + await expect(group).toBeVisible(); + + // Default value should be appContainer + const trigger = group.locator('.custom-select-trigger'); + expect(await trigger.textContent()).toContain('appContainer'); +}); + +test('can change Trust Level to mediumIL', async () => { + const card = frame.locator('.app-card').first(); + const group = card.locator('#app-0-trustlevel-group'); + const trigger = group.locator('.custom-select-trigger'); + await trigger.click(); + await group.locator('.custom-select-option[data-value="mediumIL"]').click(); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('TrustLevel="mediumIL"'); +}); + +test('can remove Trust Level attribute', async () => { + const card = frame.locator('.app-card').first(); + const group = card.locator('#app-0-trustlevel-group'); + await group.locator('.btn-remove-field').click(); + await ctx.page.waitForTimeout(500); + + await expect(card.locator('#add-app-0-trustlevel')).toBeVisible(); +}); + +test('can add Runtime Behavior attribute', async () => { + const card = frame.locator('.app-card').first(); + const addBtn = card.locator('#add-app-0-runtimebehavior'); + await expect(addBtn).toBeVisible(); + await addBtn.click(); + await ctx.page.waitForTimeout(500); + + const group = card.locator('#app-0-runtimebehavior-group'); + await expect(group).toBeVisible(); + + const trigger = group.locator('.custom-select-trigger'); + expect(await trigger.textContent()).toContain('windowsApp'); +}); + +test('can change Runtime Behavior to packagedClassicApp', async () => { + const card = frame.locator('.app-card').first(); + const group = card.locator('#app-0-runtimebehavior-group'); + const trigger = group.locator('.custom-select-trigger'); + await trigger.click(); + await group.locator('.custom-select-option[data-value="packagedClassicApp"]').click(); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('RuntimeBehavior="packagedClassicApp"'); +}); + +test('can remove Runtime Behavior attribute', async () => { + const card = frame.locator('.app-card').first(); + const group = card.locator('#app-0-runtimebehavior-group'); + await group.locator('.btn-remove-field').click(); + await ctx.page.waitForTimeout(500); + + await expect(card.locator('#add-app-0-runtimebehavior')).toBeVisible(); +}); + +test('can add Supports Multiple Instances attribute', async () => { + const card = frame.locator('.app-card').first(); + const addBtn = card.locator('#add-app-0-multiinstance'); + await expect(addBtn).toBeVisible(); + await addBtn.click(); + await ctx.page.waitForTimeout(500); + + const group = card.locator('#app-0-multiinstance-group'); + await expect(group).toBeVisible(); + + const trigger = group.locator('.custom-select-trigger'); + expect(await trigger.textContent()).toContain('true'); +}); + +test('can remove Supports Multiple Instances attribute', async () => { + const card = frame.locator('.app-card').first(); + const group = card.locator('#app-0-multiinstance-group'); + await group.locator('.btn-remove-field').click(); + await ctx.page.waitForTimeout(500); + + await expect(card.locator('#add-app-0-multiinstance')).toBeVisible(); +}); + +test('can add Parameters attribute', async () => { + const card = frame.locator('.app-card').first(); + const addBtn = card.locator('#add-app-0-parameters'); + await expect(addBtn).toBeVisible(); + await addBtn.click(); + await ctx.page.waitForTimeout(500); + + const group = card.locator('#app-0-parameters-group'); + await expect(group).toBeVisible(); + + const input = group.locator('input[data-field-name="parameters"]'); + await input.fill('--verbose --port 8080'); + await input.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('--verbose --port 8080'); +}); + +test('can remove Parameters attribute', async () => { + const card = frame.locator('.app-card').first(); + const group = card.locator('#app-0-parameters-group'); + await group.locator('.btn-remove-field').click(); + await ctx.page.waitForTimeout(500); + + await expect(card.locator('#add-app-0-parameters')).toBeVisible(); +}); + +// ─── More extension types ─────────────────────────────── + +test('can add a COM Server extension', async () => { + await switchAppSubTab(frame, 0, 'extensions'); + await ctx.page.waitForTimeout(500); + + const card = frame.locator('.app-card').first(); + const extContent = card.locator('.app-sub-content[data-subcontent="extensions"]'); + const initialCount = await extContent.locator('.list-item').count(); + + await card.locator('.add-ext-btn').click(); + await ctx.page.waitForTimeout(300); + await card.locator('.add-ext-item:has-text("COM Server")').click(); + await ctx.page.waitForTimeout(1_000); + + expect(await extContent.locator('.list-item').count()).toBe(initialCount + 1); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('windows.comServer'); +}); + +test('can add an App Execution Alias extension', async () => { + const card = frame.locator('.app-card').first(); + const extContent = card.locator('.app-sub-content[data-subcontent="extensions"]'); + const initialCount = await extContent.locator('.list-item').count(); + + await card.locator('.add-ext-btn').click(); + await ctx.page.waitForTimeout(300); + await card.locator('.add-ext-item:has-text("App Execution Alias")').click(); + await ctx.page.waitForTimeout(1_000); + + expect(await extContent.locator('.list-item').count()).toBe(initialCount + 1); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('windows.appExecutionAlias'); +}); + +test('can add a Background Tasks extension', async () => { + const card = frame.locator('.app-card').first(); + const extContent = card.locator('.app-sub-content[data-subcontent="extensions"]'); + const initialCount = await extContent.locator('.list-item').count(); + + await card.locator('.add-ext-btn').click(); + await ctx.page.waitForTimeout(300); + await card.locator('.add-ext-item:has-text("Background Tasks")').click(); + await ctx.page.waitForTimeout(1_000); + + expect(await extContent.locator('.list-item').count()).toBe(initialCount + 1); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('windows.backgroundTasks'); +}); + +test('can add a File Type Association extension', async () => { + const card = frame.locator('.app-card').first(); + const extContent = card.locator('.app-sub-content[data-subcontent="extensions"]'); + const initialCount = await extContent.locator('.list-item').count(); + + await card.locator('.add-ext-btn').click(); + await ctx.page.waitForTimeout(300); + await card.locator('.add-ext-item:has-text("File Type Association")').click(); + await ctx.page.waitForTimeout(1_000); + + expect(await extContent.locator('.list-item').count()).toBe(initialCount + 1); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('windows.fileTypeAssociation'); +}); + +test('can add a Startup Task extension', async () => { + const card = frame.locator('.app-card').first(); + const extContent = card.locator('.app-sub-content[data-subcontent="extensions"]'); + const initialCount = await extContent.locator('.list-item').count(); + + await card.locator('.add-ext-btn').click(); + await ctx.page.waitForTimeout(300); + await card.locator('.add-ext-item:has-text("Startup Task")').click(); + await ctx.page.waitForTimeout(1_000); + + expect(await extContent.locator('.list-item').count()).toBe(initialCount + 1); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('windows.startupTask'); +}); + +test('can add a Share Target extension', async () => { + const card = frame.locator('.app-card').first(); + const extContent = card.locator('.app-sub-content[data-subcontent="extensions"]'); + const initialCount = await extContent.locator('.list-item').count(); + + await card.locator('.add-ext-btn').click(); + await ctx.page.waitForTimeout(300); + await card.locator('.add-ext-item:has-text("Share Target")').click(); + await ctx.page.waitForTimeout(1_000); + + expect(await extContent.locator('.list-item').count()).toBe(initialCount + 1); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('windows.shareTarget'); +}); + +test('can add an App Service extension', async () => { + const card = frame.locator('.app-card').first(); + const extContent = card.locator('.app-sub-content[data-subcontent="extensions"]'); + const initialCount = await extContent.locator('.list-item').count(); + + await card.locator('.add-ext-btn').click(); + await ctx.page.waitForTimeout(300); + await card.locator('.add-ext-item:has-text("App Service")').click(); + await ctx.page.waitForTimeout(1_000); + + expect(await extContent.locator('.list-item').count()).toBe(initialCount + 1); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('windows.appService'); +}); + +test('can add a Toast Notification Activation extension', async () => { + const card = frame.locator('.app-card').first(); + const extContent = card.locator('.app-sub-content[data-subcontent="extensions"]'); + const initialCount = await extContent.locator('.list-item').count(); + + await card.locator('.add-ext-btn').click(); + await ctx.page.waitForTimeout(300); + await card.locator('.add-ext-item:has-text("Toast Notification")').click(); + await ctx.page.waitForTimeout(1_000); + + expect(await extContent.locator('.list-item').count()).toBe(initialCount + 1); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('windows.toastNotificationActivation'); +}); + +test('can add an MCP Server extension', async () => { + const card = frame.locator('.app-card').first(); + const extContent = card.locator('.app-sub-content[data-subcontent="extensions"]'); + const initialCount = await extContent.locator('.list-item').count(); + + await card.locator('.add-ext-btn').click(); + await ctx.page.waitForTimeout(300); + await card.locator('.add-ext-item:has-text("MCP Server")').click(); + await ctx.page.waitForTimeout(1_000); + + expect(await extContent.locator('.list-item').count()).toBe(initialCount + 1); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('windows.appExtension'); +}); + +// ─── Add/Remove application ───────────────────────────── + +test('add application button is visible', async () => { + await expect(frame.locator('#add-application-btn')).toBeVisible(); +}); + +test('can add a new application', async () => { + const initial = await frame.locator('.app-card').count(); + await clickButton(frame, 'add-application-btn'); + await ctx.page.waitForTimeout(1_500); + + const updated = await frame.locator('.app-card').count(); + expect(updated).toBe(initial + 1); +}); + +test('can remove the newly added application', async () => { + const initial = await frame.locator('.app-card').count(); + const lastCard = frame.locator('.app-card').last(); + await lastCard.locator('button:has-text("✕"), .btn-remove-section').first().click(); + await ctx.page.waitForTimeout(1_000); + + const updated = await frame.locator('.app-card').count(); + expect(updated).toBe(initial - 1); +}); + +// ─── Browse executable button ─────────────────────────── + +test('browse executable button is visible', async () => { + // Switch back to Info tab + await switchAppSubTab(frame, 0, 'info'); + await ctx.page.waitForTimeout(500); + + const card = frame.locator('.app-card').first(); + const browseBtn = card.locator('button:has-text("Browse"), .browse-exe-btn').first(); + await expect(browseBtn).toBeVisible(); +}); + +// ─── Image path warning & copy to assets ──────────────── + +test('shows warning for image path not in package directory', async () => { + const card = frame.locator('.app-card').first(); + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + // Create a temp image file outside the workspace + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'img-test-')); + const externalImg = path.join(tmpDir, 'external-logo.png'); + // Write a minimal valid PNG (1x1 pixel) + const pngBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64' + ); + fs.writeFileSync(externalImg, pngBuffer); + + // Type the external path into Square 150x150 Logo field + const input = card.locator('input[data-field-name="visualElements.square150x150Logo"]'); + await input.fill(externalImg); + await input.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(2_000); + + // Check the warning appears with "Copy to Assets" link + const formGroup = card.locator('.form-group[data-field*="visualElements.square150x150Logo"]'); + const validationMsg = formGroup.locator('.validation-msg'); + await expect(validationMsg).toContainText('Image not in package directory.', { timeout: 10_000 }); + const copyLink = validationMsg.locator('.copy-to-assets-link'); + await expect(copyLink).toBeVisible(); + + // Clean up + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('copy to assets copies file and updates field path', async () => { + const card = frame.locator('.app-card').first(); + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + // Create a temp image file outside the workspace + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'img-copy-')); + const externalImg = path.join(tmpDir, 'test-asset.png'); + const pngBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64' + ); + fs.writeFileSync(externalImg, pngBuffer); + + // Type the external path into Square 150x150 Logo field + const input = card.locator('input[data-field-name="visualElements.square150x150Logo"]'); + await input.fill(externalImg); + await input.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(2_000); + + // Click "Copy to Assets folder" link + const formGroup = card.locator('.form-group[data-field*="visualElements.square150x150Logo"]'); + const copyLink = formGroup.locator('.copy-to-assets-link'); + if (await copyLink.count() > 0 && await copyLink.isVisible()) { + await copyLink.click(); + await ctx.page.waitForTimeout(2_000); + + // Verify file was copied to Assets folder + const assetsDir = path.join(ctx.workspacePath, 'Assets'); + expect(fs.existsSync(assetsDir)).toBe(true); + const copiedFile = path.join(assetsDir, 'test-asset.png'); + expect(fs.existsSync(copiedFile)).toBe(true); + + // Verify the manifest XML was updated + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Assets\\test-asset.png'); + } + + // Clean up + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('no warning shown for resource key paths', async () => { + const card = frame.locator('.app-card').first(); + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + // Type a resource key (no image extension) into the logo field + const input = card.locator('input[data-field-name="visualElements.square150x150Logo"]'); + await input.fill('ms-appx:///Resources/Logo'); + await input.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_500); + + // No warning should appear + const formGroup = card.locator('.form-group[data-field*="visualElements.square150x150Logo"]'); + const validationMsg = formGroup.locator('.validation-msg'); + await expect(validationMsg).not.toContainText('Image not'); +}); + +// ─── Aspect ratio validation ──────────────────────────── + +/** Creates a minimal valid PNG with given dimensions (1-pixel transparency). */ +function createPngWithDimensions(width: number, height: number): Buffer { + // PNG signature + const signature = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + // IHDR chunk: width(4) + height(4) + bitDepth(1) + colorType(1) + compression(1) + filter(1) + interlace(1) + const ihdrData = Buffer.alloc(13); + ihdrData.writeUInt32BE(width, 0); + ihdrData.writeUInt32BE(height, 4); + ihdrData[8] = 8; // bit depth + ihdrData[9] = 2; // color type (RGB) + ihdrData[10] = 0; // compression + ihdrData[11] = 0; // filter + ihdrData[12] = 0; // interlace + + const ihdrLength = Buffer.alloc(4); + ihdrLength.writeUInt32BE(13, 0); + const ihdrType = Buffer.from('IHDR'); + const ihdrCrc = crc32(Buffer.concat([ihdrType, ihdrData])); + + // IDAT chunk (minimal — single row of zeroes compressed with zlib) + const zlib = require('zlib'); + const rawData = Buffer.alloc((width * 3 + 1) * height, 0); // filter byte + RGB per pixel per row + const compressed = zlib.deflateSync(rawData); + const idatLength = Buffer.alloc(4); + idatLength.writeUInt32BE(compressed.length, 0); + const idatType = Buffer.from('IDAT'); + const idatCrc = crc32(Buffer.concat([idatType, compressed])); + + // IEND chunk + const iendLength = Buffer.from([0, 0, 0, 0]); + const iendType = Buffer.from('IEND'); + const iendCrc = crc32(iendType); + + return Buffer.concat([ + signature, + ihdrLength, ihdrType, ihdrData, ihdrCrc, + idatLength, idatType, compressed, idatCrc, + iendLength, iendType, iendCrc, + ]); +} + +/** CRC32 for PNG chunks. */ +function crc32(buf: Buffer): Buffer { + let crc = 0xFFFFFFFF; + for (let i = 0; i < buf.length; i++) { + crc ^= buf[i]; + for (let j = 0; j < 8; j++) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0); + } + } + const result = Buffer.alloc(4); + result.writeUInt32BE((crc ^ 0xFFFFFFFF) >>> 0, 0); + return result; +} + +test('shows aspect ratio warning for non-square image in square field', async () => { + const card = frame.locator('.app-card').first(); + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + // Create a 200x100 (2:1) PNG in the workspace Assets folder + const assetsDir = path.join(ctx.workspacePath, 'Assets'); + if (!fs.existsSync(assetsDir)) { fs.mkdirSync(assetsDir); } + const imgPath = path.join(assetsDir, 'wide-test.png'); + fs.writeFileSync(imgPath, createPngWithDimensions(200, 100)); + + // Set the field to the in-package path + const input = card.locator('input[data-field-name="visualElements.square150x150Logo"]'); + await input.fill('Assets\\wide-test.png'); + await input.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(2_000); + + // Should show aspect ratio warning + const formGroup = card.locator('.form-group[data-field*="visualElements.square150x150Logo"]'); + const validationMsg = formGroup.locator('.validation-msg'); + await expect(validationMsg).toContainText('expected 1:1 (square) aspect ratio', { timeout: 10_000 }); + await expect(validationMsg).toContainText('200×100'); + + // Clean up + fs.rmSync(imgPath, { force: true }); +}); + +test('no aspect ratio warning for correct square image', async () => { + const card = frame.locator('.app-card').first(); + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + // Create a 150x150 (1:1) PNG in the workspace Assets folder + const assetsDir = path.join(ctx.workspacePath, 'Assets'); + if (!fs.existsSync(assetsDir)) { fs.mkdirSync(assetsDir); } + const imgPath = path.join(assetsDir, 'square-test.png'); + fs.writeFileSync(imgPath, createPngWithDimensions(150, 150)); + + // Set the field to the in-package path + const input = card.locator('input[data-field-name="visualElements.square150x150Logo"]'); + await input.fill('Assets\\square-test.png'); + await input.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(2_000); + + // Should NOT show any warning + const formGroup = card.locator('.form-group[data-field*="visualElements.square150x150Logo"]'); + const validationMsg = formGroup.locator('.validation-msg'); + await expect(validationMsg).not.toContainText('aspect ratio'); + + // Clean up + fs.rmSync(imgPath, { force: true }); +}); + +test('shows aspect ratio warning for square image in wide field', async () => { + const card = frame.locator('.app-card').first(); + await switchAppSubTab(frame, 0, 'visual'); + await ctx.page.waitForTimeout(500); + + // First add the wide310x150 field if not present + const addWideBtn = card.locator('button:has-text("Wide 310x150")'); + if (await addWideBtn.count() > 0 && await addWideBtn.isVisible()) { + await addWideBtn.click(); + await ctx.page.waitForTimeout(1_000); + } + + // Create a 100x100 (1:1) PNG — wrong for wide field + const assetsDir = path.join(ctx.workspacePath, 'Assets'); + if (!fs.existsSync(assetsDir)) { fs.mkdirSync(assetsDir); } + const imgPath = path.join(assetsDir, 'square-for-wide.png'); + fs.writeFileSync(imgPath, createPngWithDimensions(100, 100)); + + // Set the wide field to this image + const input = card.locator('input[data-field-name="visualElements.wide310x150Logo"]'); + await input.fill('Assets\\square-for-wide.png'); + await input.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(2_000); + + // Should show aspect ratio warning + const formGroup = card.locator('.form-group[data-field*="visualElements.wide310x150Logo"]'); + const validationMsg = formGroup.locator('.validation-msg'); + await expect(validationMsg).toContainText('expected 310:150 (wide) aspect ratio', { timeout: 10_000 }); + + // Clean up + fs.rmSync(imgPath, { force: true }); +}); diff --git a/src/winapp-VSC/src/test/e2e/background-task-fixture.spec.ts b/src/winapp-VSC/src/test/e2e/background-task-fixture.spec.ts new file mode 100644 index 00000000..e476102a --- /dev/null +++ b/src/winapp-VSC/src/test/e2e/background-task-fixture.spec.ts @@ -0,0 +1,144 @@ +/** + * E2E tests: Background Task sample fixture. + * Verifies the editor handles a manifest with PhoneIdentity, multiple + * device families, various dependency types, custom capabilities, and + * background task extensions. + */ + +import { test, expect, type FrameLocator } from '@playwright/test'; +import { + switchTab, + getInputValue, + isCapabilityChecked, + countListItems, + readManifestXml, + setInputValue, + waitForDebounce, + type VSCodeTestContext, +} from './helpers'; +import { ensureEditor, resetManifest } from './shared-context'; + +const FIXTURE = 'background-task-sample.appxmanifest'; + +let ctx: VSCodeTestContext; +let frame: FrameLocator; + +test.beforeAll(async () => { + const shared = await ensureEditor(); + ctx = shared.ctx; + frame = await resetManifest(ctx, FIXTURE); +}); + +// ─── Identity ─────────────────────────────────────────── + +test('identity fields are populated correctly', async () => { + await switchTab(frame, 'identity'); + expect(await getInputValue(frame, 'identity-name')).toBe('6b1ec254-6909-4115-a6f6-1133733eb38e'); + expect(await getInputValue(frame, 'identity-publisher')).toBe( + 'CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US' + ); + expect(await getInputValue(frame, 'identity-version')).toBe('1.0.0.0'); +}); + +test('PhoneIdentity section is visible', async () => { + await expect(frame.locator('#phone-identity-section')).toBeVisible(); +}); + +test('PhoneIdentity fields are populated', async () => { + const productId = await getInputValue(frame, 'phone-product-id'); + expect(productId).toBe('bb526a1f-8e02-4523-b45e-e2ee91c4c65b'); + const publisherId = await getInputValue(frame, 'phone-publisher-id'); + expect(publisherId).toBe('00000000-0000-0000-0000-000000000000'); +}); + +// ─── Properties ───────────────────────────────────────── + +test('properties fields are populated correctly', async () => { + await switchTab(frame, 'properties'); + expect(await getInputValue(frame, 'props-displayname')).toBe('BackgroundTaskBuilder'); + expect(await getInputValue(frame, 'props-pubdisplayname')).toBe('Microsoft'); + expect(await getInputValue(frame, 'props-logo')).toBe('Assets\\StoreLogo'); +}); + +// ─── Dependencies ─────────────────────────────────────── + +test('has two target device families', async () => { + await switchTab(frame, 'dependencies'); + const count = await countListItems(frame, 'target-device-families'); + expect(count).toBe(2); +}); + +test('first device family is Windows.Universal', async () => { + const title = await frame.locator('#target-device-families .list-item').first().locator('.item-title').textContent(); + expect(title).toContain('Windows.Universal'); +}); + +test('second device family is Windows.Desktop', async () => { + const title = await frame.locator('#target-device-families .list-item').nth(1).locator('.item-title').textContent(); + expect(title).toContain('Windows.Desktop'); +}); + +test('main package dependencies are present', async () => { + const count = await countListItems(frame, 'main-package-dependencies'); + expect(count).toBeGreaterThanOrEqual(1); +}); + +test('driver constraints are present', async () => { + const count = await countListItems(frame, 'driver-constraints'); + expect(count).toBeGreaterThanOrEqual(1); +}); + +test('OS package dependencies are present', async () => { + const count = await countListItems(frame, 'os-package-dependencies'); + expect(count).toBeGreaterThanOrEqual(1); +}); + +test('host runtime dependencies are present', async () => { + const count = await countListItems(frame, 'host-runtime-dependencies'); + expect(count).toBeGreaterThanOrEqual(1); +}); + +test('external dependencies are present', async () => { + const count = await countListItems(frame, 'external-dependencies'); + expect(count).toBeGreaterThanOrEqual(1); +}); + +// ─── Resources ────────────────────────────────────────── + +test('resources section has no items (empty Resources element)', async () => { + await switchTab(frame, 'resources'); + const count = await countListItems(frame, 'resources-list'); + expect(count).toBe(0); +}); + +// ─── Applications ─────────────────────────────────────── + +test('application card is present', async () => { + await switchTab(frame, 'applications'); + expect(await frame.locator('.app-card').count()).toBe(1); + const idInput = frame.locator('.app-card').first().locator('input[data-field-name="id"]'); + expect(await idInput.inputValue()).toBe('App'); +}); + +// ─── Capabilities ─────────────────────────────────────── + +test('runFullTrust restricted capability is checked', async () => { + await switchTab(frame, 'capabilities'); + expect(await isCapabilityChecked(frame, 'rescap:runFullTrust')).toBe(true); +}); + +test('custom capability is listed', async () => { + const customList = frame.locator('#custom-caps-list .custom-cap-entry'); + const count = await customList.count(); + expect(count).toBeGreaterThanOrEqual(1); +}); + +// ─── Edit round-trip ──────────────────────────────────── + +test('editing display name persists to XML', async () => { + await switchTab(frame, 'properties'); + await setInputValue(frame, 'props-displayname', 'BackgroundTaskEdited'); + await waitForDebounce(ctx.page); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('BackgroundTaskEdited'); +}); diff --git a/src/winapp-VSC/src/test/e2e/capabilities-tab.spec.ts b/src/winapp-VSC/src/test/e2e/capabilities-tab.spec.ts new file mode 100644 index 00000000..eef3b21d --- /dev/null +++ b/src/winapp-VSC/src/test/e2e/capabilities-tab.spec.ts @@ -0,0 +1,222 @@ +/** + * E2E tests: Capabilities tab – checkbox toggles, custom capabilities, + * hover descriptions, and validation. + */ + +import { test, expect, type FrameLocator } from '@playwright/test'; +import { + switchTab, + toggleCapability, + isCapabilityChecked, + clickButton, + setInputValue, + waitForDebounce, + readManifestXml, + type VSCodeTestContext, +} from './helpers'; +import { ensureEditor, resetManifest } from './shared-context'; + +let ctx: VSCodeTestContext; +let frame: FrameLocator; + +test.beforeAll(async () => { + const shared = await ensureEditor(); + ctx = shared.ctx; + frame = await resetManifest(ctx); + await switchTab(frame, 'capabilities'); +}); + +// ─── Capability categories ────────────────────────────── + +test('General capabilities section is visible', async () => { + await expect(frame.locator('.cap-category-title:has-text("General")')).toBeVisible(); +}); + +test('Restricted capabilities section is visible', async () => { + await expect(frame.locator('.cap-category-title:has-text("Restricted")')).toBeVisible(); +}); + +test('Device capabilities section is visible', async () => { + await expect(frame.locator('.cap-category-title:has-text("Device")')).toBeVisible(); +}); + +test('Custom Capability section is visible', async () => { + await expect(frame.locator('.cap-category-title:has-text("Custom")')).toBeVisible(); +}); + +// ─── Existing capabilities from fixture ───────────────── + +test('runFullTrust capability is checked (from fixture)', async () => { + const checked = await isCapabilityChecked(frame, 'rescap:runFullTrust'); + expect(checked).toBe(true); +}); + +// ─── Toggle capabilities ──────────────────────────────── + +test('can check internetClient capability', async () => { + await toggleCapability(frame, 'internetClient'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const checked = await isCapabilityChecked(frame, 'internetClient'); + expect(checked).toBe(true); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('internetClient'); +}); + +test('can uncheck internetClient capability', async () => { + await toggleCapability(frame, 'internetClient'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const checked = await isCapabilityChecked(frame, 'internetClient'); + expect(checked).toBe(false); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).not.toContain('"internetClient"'); +}); + +test('can toggle a device capability', async () => { + await toggleCapability(frame, 'device:microphone'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const checked = await isCapabilityChecked(frame, 'device:microphone'); + expect(checked).toBe(true); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('microphone'); + + // Toggle back off + await toggleCapability(frame, 'device:microphone'); + await ctx.page.waitForTimeout(1_000); +}); + +// ─── System AI capability ──────────────────────────────── + +test('can add systemAIModels capability', async () => { + await toggleCapability(frame, 'systemai:systemAIModels'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const checked = await isCapabilityChecked(frame, 'systemai:systemAIModels'); + expect(checked).toBe(true); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('systemAIModels'); + expect(xml).toContain('schemas.microsoft.com/appx/manifest/systemai/windows10'); +}); + +test('can remove systemAIModels capability', async () => { + await toggleCapability(frame, 'systemai:systemAIModels'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const checked = await isCapabilityChecked(frame, 'systemai:systemAIModels'); + expect(checked).toBe(false); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).not.toContain('systemAIModels'); + // xmlns:systemai should be removed when no longer used + expect(xml).not.toContain('xmlns:systemai'); +}); + +// ─── Namespace cleanup on capability removal ───────────── + +test('removing last rescap capability removes xmlns:rescap', async () => { + // The fixture has rescap:runFullTrust checked — uncheck it + await toggleCapability(frame, 'rescap:runFullTrust'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).not.toContain('runFullTrust'); + expect(xml).not.toContain('xmlns:rescap'); +}); + +test('re-adding rescap capability restores xmlns:rescap', async () => { + await toggleCapability(frame, 'rescap:runFullTrust'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('runFullTrust'); + expect(xml).toContain('xmlns:rescap'); +}); + +// ─── Capability description panel ─────────────────────── + +test('description panel exists', async () => { + await expect(frame.locator('#cap-description-panel')).toBeVisible(); + await expect(frame.locator('#cap-description-text')).toBeVisible(); +}); + +test('hovering a capability shows description', async () => { + // Hover over the internetClient capability + await frame.locator('.cap-item[data-cap="internetClient"]').hover(); + await ctx.page.waitForTimeout(500); + + const descText = await frame.locator('#cap-description-text').textContent(); + expect(descText).toBeTruthy(); + expect(descText!.length).toBeGreaterThan(10); +}); + +// ─── Custom capabilities ──────────────────────────────── + +test('custom capability input is visible', async () => { + await expect(frame.locator('#custom-cap-input')).toBeVisible(); + await expect(frame.locator('#add-custom-cap')).toBeVisible(); +}); + +test('adding empty custom capability shows error', async () => { + await clickButton(frame, 'add-custom-cap'); + await ctx.page.waitForTimeout(500); + + const errorEl = frame.locator('#custom-cap-error'); + await expect(errorEl).toBeVisible(); + const errorText = await errorEl.textContent(); + expect(errorText).toContain('required'); +}); + +test('adding invalid format custom capability shows error', async () => { + const input = frame.locator('#custom-cap-input'); + await input.fill('invalid-capability'); + await clickButton(frame, 'add-custom-cap'); + await ctx.page.waitForTimeout(500); + + const errorEl = frame.locator('#custom-cap-error'); + await expect(errorEl).toBeVisible(); + const errorText = await errorEl.textContent(); + expect(errorText).toContain('format'); +}); + +test('typing in input clears validation error', async () => { + const input = frame.locator('#custom-cap-input'); + await input.fill('a'); + await ctx.page.waitForTimeout(300); + + const errorEl = frame.locator('#custom-cap-error'); + // Error should be hidden after typing + await expect(errorEl).not.toBeVisible(); +}); + +test('adding valid custom capability succeeds', async () => { + const input = frame.locator('#custom-cap-input'); + await input.fill('Contoso.Devices.SerialCommunication_0wer1ey63g7b4'); + await clickButton(frame, 'add-custom-cap'); + await ctx.page.waitForTimeout(1_000); + + // Input should be cleared + const val = await input.inputValue(); + expect(val).toBe(''); + + // The capability should appear in the XML + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Contoso.Devices.SerialCommunication_0wer1ey63g7b4'); +}); + +test('custom capability appears in the custom capabilities list', async () => { + const customList = frame.locator('#custom-caps-list'); + await expect(customList).toContainText('Contoso.Devices.SerialCommunication'); +}); diff --git a/src/winapp-VSC/src/test/e2e/dependencies-tab.spec.ts b/src/winapp-VSC/src/test/e2e/dependencies-tab.spec.ts new file mode 100644 index 00000000..802cb085 --- /dev/null +++ b/src/winapp-VSC/src/test/e2e/dependencies-tab.spec.ts @@ -0,0 +1,169 @@ +/** + * E2E tests: Dependencies tab – target device families, package dependencies, + * and other dependency types (add, remove, move, edit). + */ + +import { test, expect, type FrameLocator } from '@playwright/test'; +import { + switchTab, + clickButton, + countListItems, + waitForDebounce, + readManifestXml, + type VSCodeTestContext, +} from './helpers'; +import { ensureEditor, resetManifest } from './shared-context'; + +let ctx: VSCodeTestContext; +let frame: FrameLocator; + +test.beforeAll(async () => { + const shared = await ensureEditor(); + ctx = shared.ctx; + frame = await resetManifest(ctx); + await switchTab(frame, 'dependencies'); +}); + +// ─── Target Device Families ───────────────────────────── + +test('shows existing target device family from fixture', async () => { + const items = await countListItems(frame, 'target-device-families'); + expect(items).toBeGreaterThanOrEqual(1); +}); + +test('target device family fields are populated', async () => { + const firstItem = frame.locator('#target-device-families .list-item').first(); + // The name is displayed in the header title, not as an input field + const title = firstItem.locator('.item-title'); + const titleText = await title.textContent(); + expect(titleText).toContain('Windows.Desktop'); +}); + +test('add target device family dropdown is visible', async () => { + await expect(frame.locator('#add-target-family')).toBeVisible(); +}); + +test('can add a target device family via dropdown', async () => { + // Open the dropdown + await frame.locator('#add-target-family').click(); + await frame.locator('#add-family-menu').waitFor({ state: 'visible' }); + + // Click "Windows.Universal" or the first available option + const firstOption = frame.locator('#add-family-menu .custom-dropdown-item').first(); + await firstOption.click(); + await ctx.page.waitForTimeout(1_000); + + const items = await countListItems(frame, 'target-device-families'); + expect(items).toBeGreaterThanOrEqual(2); +}); + +test('can edit target device family minVersion', async () => { + const secondItem = frame.locator('#target-device-families .list-item').nth(1); + const minVersionInput = secondItem.locator('input[data-field-name="targetDeviceFamily.minVersion"]'); + await minVersionInput.fill('10.0.19041.0'); + await minVersionInput.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('MinVersion="10.0.19041.0"'); +}); + +test('can remove a target device family', async () => { + const initialCount = await countListItems(frame, 'target-device-families'); + const lastItem = frame.locator('#target-device-families .list-item').last(); + await lastItem.locator('.btn-remove-field, button:has-text("✕")').first().click(); + await ctx.page.waitForTimeout(1_000); + + const newCount = await countListItems(frame, 'target-device-families'); + expect(newCount).toBe(initialCount - 1); +}); + +// ─── Package Dependencies ─────────────────────────────── + +test('add package dependency button is visible', async () => { + await expect(frame.locator('#add-package-dep')).toBeVisible(); +}); + +test('can add a package dependency', async () => { + const initialCount = await countListItems(frame, 'package-dependencies'); + await clickButton(frame, 'add-package-dep'); + await ctx.page.waitForTimeout(1_000); + + const newCount = await countListItems(frame, 'package-dependencies'); + expect(newCount).toBe(initialCount + 1); +}); + +test('can edit package dependency name', async () => { + const lastItem = frame.locator('#package-dependencies .list-item').last(); + const nameInput = lastItem.locator('input[data-field-name="packageDependency.name"]'); + await nameInput.fill('Microsoft.VCLibs.140.00'); + await nameInput.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Microsoft.VCLibs.140.00'); +}); + +test('can remove a package dependency', async () => { + const initialCount = await countListItems(frame, 'package-dependencies'); + const lastItem = frame.locator('#package-dependencies .list-item').last(); + await lastItem.locator('.btn-remove-field, button:has-text("✕")').first().click(); + await ctx.page.waitForTimeout(1_000); + + const newCount = await countListItems(frame, 'package-dependencies'); + expect(newCount).toBe(initialCount - 1); +}); + +// ─── Other dependency types ───────────────────────────── + +test('add main package dependency button is visible', async () => { + await expect(frame.locator('#add-main-pkg-dep')).toBeVisible(); +}); + +test('add driver constraint button is visible', async () => { + await expect(frame.locator('#add-driver-constraint')).toBeVisible(); +}); + +test('add OS package dependency button is visible', async () => { + await expect(frame.locator('#add-os-pkg-dep')).toBeVisible(); +}); + +test('add host runtime dependency button is visible', async () => { + await expect(frame.locator('#add-host-runtime-dep')).toBeVisible(); +}); + +test('add external dependency button is visible', async () => { + await expect(frame.locator('#add-external-dep')).toBeVisible(); +}); + +test('can add and remove a main package dependency', async () => { + await clickButton(frame, 'add-main-pkg-dep'); + await ctx.page.waitForTimeout(1_000); + + let count = await countListItems(frame, 'main-package-dependencies'); + expect(count).toBeGreaterThanOrEqual(1); + + const lastItem = frame.locator('#main-package-dependencies .list-item').last(); + await lastItem.locator('button:has-text("✕"), .btn-remove-section').first().click(); + await ctx.page.waitForTimeout(1_000); + + count = await countListItems(frame, 'main-package-dependencies'); + expect(count).toBe(0); +}); + +test('can add and remove an external dependency', async () => { + await clickButton(frame, 'add-external-dep'); + await ctx.page.waitForTimeout(1_000); + + let count = await countListItems(frame, 'external-dependencies'); + expect(count).toBeGreaterThanOrEqual(1); + + const lastItem = frame.locator('#external-dependencies .list-item').last(); + await lastItem.locator('button:has-text("✕"), .btn-remove-section').first().click(); + await ctx.page.waitForTimeout(1_000); + + count = await countListItems(frame, 'external-dependencies'); + expect(count).toBe(0); +}); diff --git a/src/winapp-VSC/src/test/e2e/editor-launch.spec.ts b/src/winapp-VSC/src/test/e2e/editor-launch.spec.ts new file mode 100644 index 00000000..44e82cb5 --- /dev/null +++ b/src/winapp-VSC/src/test/e2e/editor-launch.spec.ts @@ -0,0 +1,89 @@ +/** + * E2E tests: Editor launch, tab navigation, and View XML button. + */ + +import { test, expect, type FrameLocator } from '@playwright/test'; +import { + switchTab, + type VSCodeTestContext, +} from './helpers'; +import { ensureEditor, resetManifest } from './shared-context'; + +let ctx: VSCodeTestContext; +let frame: FrameLocator; + +test.beforeAll(async () => { + const shared = await ensureEditor(); + ctx = shared.ctx; + frame = await resetManifest(ctx); + await switchTab(frame, 'identity'); +}); + +// ─── Launch ───────────────────────────────────────────── + +test('manifest editor opens and shows tab bar', async () => { + await expect(frame.locator('.tab-bar')).toBeVisible(); +}); + +test('Identity tab is active by default', async () => { + const btn = frame.locator('.tab-btn[data-tab="identity"]'); + await expect(btn).toHaveAttribute('aria-selected', 'true'); + await expect(frame.locator('#tab-identity')).toBeVisible(); +}); + +test('all six tabs are visible', async () => { + const tabs = ['identity', 'properties', 'dependencies', 'resources', 'applications', 'capabilities']; + for (const tab of tabs) { + await expect(frame.locator(`.tab-btn[data-tab="${tab}"]`)).toBeVisible(); + } +}); + +// ─── Tab switching ────────────────────────────────────── + +test('can switch to Properties tab', async () => { + await switchTab(frame, 'properties'); + await expect(frame.locator('#tab-properties')).toBeVisible(); + await expect(frame.locator('#tab-identity')).not.toBeVisible(); +}); + +test('can switch to Dependencies tab', async () => { + await switchTab(frame, 'dependencies'); + await expect(frame.locator('#tab-dependencies')).toBeVisible(); +}); + +test('can switch to Resources tab', async () => { + await switchTab(frame, 'resources'); + await expect(frame.locator('#tab-resources')).toBeVisible(); +}); + +test('can switch to Applications tab', async () => { + await switchTab(frame, 'applications'); + await expect(frame.locator('#tab-applications')).toBeVisible(); +}); + +test('can switch to Capabilities tab', async () => { + await switchTab(frame, 'capabilities'); + await expect(frame.locator('#tab-capabilities')).toBeVisible(); +}); + +test('can switch back to Identity tab', async () => { + await switchTab(frame, 'identity'); + await expect(frame.locator('#tab-identity')).toBeVisible(); + await expect(frame.locator('.tab-btn[data-tab="identity"]')).toHaveAttribute('aria-selected', 'true'); +}); + +// ─── View XML ─────────────────────────────────────────── + +test('View XML button is visible', async () => { + await expect(frame.locator('#view-xml-btn')).toBeVisible(); + await expect(frame.locator('#view-xml-btn')).toContainText('View XML'); +}); + +// ─── Info banner ──────────────────────────────────────── + +test('info banner with feedback link is visible', async () => { + const banner = frame.locator('.info-banner'); + await expect(banner).toBeVisible(); + await expect(banner).toContainText('This editor does not support all appxmanifest customizations'); + await expect(banner.locator('a[href*="github.com"]')).toBeVisible(); +}); diff --git a/src/winapp-VSC/src/test/e2e/global-teardown.ts b/src/winapp-VSC/src/test/e2e/global-teardown.ts new file mode 100644 index 00000000..fe9e90e7 --- /dev/null +++ b/src/winapp-VSC/src/test/e2e/global-teardown.ts @@ -0,0 +1,5 @@ +import { closeSharedEditor } from './shared-context'; + +export default async function globalTeardown() { + await closeSharedEditor(); +} diff --git a/src/winapp-VSC/src/test/e2e/helpers.ts b/src/winapp-VSC/src/test/e2e/helpers.ts new file mode 100644 index 00000000..8c743b7f --- /dev/null +++ b/src/winapp-VSC/src/test/e2e/helpers.ts @@ -0,0 +1,243 @@ +/** + * Shared helpers for Playwright E2E tests of the VS Code manifest editor. + * + * Provides launch/teardown of VS Code, webview frame acquisition, and + * reusable actions (tab switching, field edits, button clicks, etc.). + */ + +import { _electron as electron, type ElectronApplication, type Page, type FrameLocator } from '@playwright/test'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; + +// ────────────────────────────────────────────────────── +// Paths +// ────────────────────────────────────────────────────── + +const VSCODE_EXE = + process.env.VSCODE_PATH ?? + path.join(os.homedir(), 'AppData', 'Local', 'Programs', 'Microsoft VS Code', 'Code.exe'); + +const EXTENSION_ROOT = path.resolve(__dirname, '..', '..', '..'); + +const FIXTURES_DIR = path.resolve(__dirname, '..', 'fixtures'); + +// ────────────────────────────────────────────────────── +// Launch helpers +// ────────────────────────────────────────────────────── + +export interface VSCodeTestContext { + app: ElectronApplication; + page: Page; + workspacePath: string; +} + +/** + * Prepares a temporary workspace with a copy of the given fixture manifest. + * Returns the path to the workspace directory. + */ +export function createTempWorkspace(fixtureName: string): string { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'manifest-e2e-')); + const src = path.join(FIXTURES_DIR, fixtureName); + const dest = path.join(tmpDir, 'AppxManifest.xml'); + fs.copyFileSync(src, dest); + return tmpDir; +} + +/** + * Launches VS Code with the extension under test, opens the given workspace, + * and returns the Electron app + main window page. + */ +export async function launchVSCode(workspacePath: string): Promise { + const manifestPath = path.join(workspacePath, 'AppxManifest.xml'); + const app = await electron.launch({ + executablePath: VSCODE_EXE, + args: [ + workspacePath, + manifestPath, + '--new-window', + `--extensionDevelopmentPath=${EXTENSION_ROOT}`, + '--disable-telemetry', + '--skip-release-notes', + '--disable-workspace-trust', + ], + timeout: 30_000, + }); + + const page = await app.firstWindow(); + // Wait for VS Code to settle + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(5_000); + + return { app, page, workspacePath }; +} + +/** + * Opens the AppxManifest.xml file in the workspace, triggering the + * custom manifest editor. Then locates and returns the webview FrameLocator. + */ +export async function openManifestEditor(page: Page): Promise { + // The file is already open from launch args. + // Reopen with the custom editor via Command Palette. + await page.keyboard.press('Control+Shift+P'); + await page.waitForTimeout(1_000); + await page.keyboard.type('View: Reopen Editor With...', { delay: 30 }); + await page.waitForTimeout(1_500); + await page.keyboard.press('Enter'); + await page.waitForTimeout(2_000); + + // Now the editor picker appears — select "AppxManifest Editor" + await page.keyboard.type('AppxManifest Editor', { delay: 30 }); + await page.waitForTimeout(1_000); + await page.keyboard.press('Enter'); + await page.waitForTimeout(5_000); + + return getWebviewFrame(page); +} + +/** + * Locates and returns the webview FrameLocator for the manifest editor. + * Useful for re-acquiring the frame after the webview reloads (e.g., fixture swap). + */ +export async function getWebviewFrame(page: Page): Promise { + // The custom editor renders inside VS Code webview frames. + // VS Code uses a named iframe for the webview container, and a nested + // iframe with name="pending-frame" (or "active-frame") for the actual content. + // We navigate the frame tree to find the inner content frame. + const webviewOuterFrame = page.frames().find(f => f.url().includes('vscode-webview://') && !f.url().includes('fake.html')); + if (!webviewOuterFrame) { + throw new Error('Could not find webview outer frame'); + } + const innerFrame = webviewOuterFrame.frameLocator('#active-frame'); + + // Wait for the editor to render (the tab bar should appear) + await innerFrame.locator('.tab-bar').waitFor({ state: 'visible', timeout: 15_000 }); + + return innerFrame; +} + +/** + * Cleans up: closes VS Code and removes the temporary workspace. + */ +export async function teardown(ctx: VSCodeTestContext): Promise { + try { + await ctx.app.close(); + } catch { /* already closed */ } + try { + fs.rmSync(ctx.workspacePath, { recursive: true, force: true }); + } catch { /* best-effort */ } +} + +// ────────────────────────────────────────────────────── +// Webview interaction helpers +// ────────────────────────────────────────────────────── + +/** Click a top-level tab by name (Identity, Properties, etc.). */ +export async function switchTab(frame: FrameLocator, tabName: string): Promise { + await frame.locator(`.tab-btn[data-tab="${tabName.toLowerCase()}"]`).click(); + await frame.locator(`#tab-${tabName.toLowerCase()}`).waitFor({ state: 'visible' }); +} + +/** Get the value of a text input by its HTML id. */ +export async function getInputValue(frame: FrameLocator, inputId: string): Promise { + return await frame.locator(`#${inputId}`).inputValue(); +} + +/** Set the value of a text input by its HTML id, clearing it first. */ +export async function setInputValue(frame: FrameLocator, inputId: string, value: string): Promise { + const input = frame.locator(`#${inputId}`); + await input.click(); + await input.fill(value); + // Trigger the debounced change handler + await input.dispatchEvent('input'); +} + +/** Click a button by its HTML id. */ +export async function clickButton(frame: FrameLocator, buttonId: string): Promise { + await frame.locator(`#${buttonId}`).click(); +} + +/** Check whether a validation error message is shown for a given field group. */ +export async function getValidationMessage(frame: FrameLocator, fieldDataAttr: string): Promise { + const group = frame.locator(`.form-group[data-field="${fieldDataAttr}"]`); + const msg = group.locator('.validation-msg'); + const text = await msg.textContent(); + return text?.trim() ?? ''; +} + +/** Check whether a form group has the error styling class. */ +export async function hasErrorClass(frame: FrameLocator, fieldDataAttr: string): Promise { + const group = frame.locator(`.form-group[data-field="${fieldDataAttr}"]`); + const cls = await group.getAttribute('class') ?? ''; + return cls.includes('has-error'); +} + +/** Check whether a form group has the warning styling class. */ +export async function hasWarningClass(frame: FrameLocator, fieldDataAttr: string): Promise { + const group = frame.locator(`.form-group[data-field="${fieldDataAttr}"]`); + const cls = await group.getAttribute('class') ?? ''; + return cls.includes('has-warning'); +} + +/** Select a value from a custom-select dropdown. */ +export async function selectCustomValue(frame: FrameLocator, selectId: string, value: string): Promise { + // Open the dropdown + await frame.locator(`#${selectId} .custom-select-trigger`).click(); + await frame.locator(`#${selectId} .custom-select-options`).waitFor({ state: 'visible' }); + // Click the option + await frame.locator(`#${selectId} .custom-select-option[data-value="${value}"]`).click(); +} + +/** Get the currently displayed value of a custom-select. */ +export async function getCustomSelectValue(frame: FrameLocator, selectId: string): Promise { + const trigger = frame.locator(`#${selectId} .custom-select-trigger`); + return (await trigger.textContent())?.trim() ?? ''; +} + +/** Toggle a capability checkbox (by data-capability attribute value). */ +export async function toggleCapability(frame: FrameLocator, capability: string): Promise { + await frame.locator(`input[data-capability="${capability}"]`).click(); +} + +/** Check whether a capability checkbox is checked. */ +export async function isCapabilityChecked(frame: FrameLocator, capability: string): Promise { + return await frame.locator(`input[data-capability="${capability}"]`).isChecked(); +} + +/** Read the file content from the workspace (for verifying XML changes). Saves the file first with Ctrl+S. */ +export async function readManifestXml(page: Page, workspacePath: string): Promise { + await page.keyboard.press('Control+S'); + await page.waitForTimeout(1_000); + return fs.readFileSync(path.join(workspacePath, 'AppxManifest.xml'), 'utf-8'); +} + +/** Wait a short time for debounced edits to propagate to the document. */ +export async function waitForDebounce(page: Page, ms = 500): Promise { + await page.waitForTimeout(ms); +} + +/** Count list items in a container by id. */ +export async function countListItems(frame: FrameLocator, containerId: string): Promise { + return await frame.locator(`#${containerId} .list-item`).count(); +} + +/** Click the Nth remove button inside a list container. */ +export async function removeListItem(frame: FrameLocator, containerId: string, index: number): Promise { + await frame.locator(`#${containerId} .list-item`).nth(index).locator('.btn-remove-field, .btn-remove-section, .btn-remove').first().click(); +} + +/** Click the move-up button on the Nth list item. */ +export async function moveListItemUp(frame: FrameLocator, containerId: string, index: number): Promise { + await frame.locator(`#${containerId} .list-item`).nth(index).locator('button:has-text("▲"), button:has-text("Move Up")').first().click(); +} + +/** Click the move-down button on the Nth list item. */ +export async function moveListItemDown(frame: FrameLocator, containerId: string, index: number): Promise { + await frame.locator(`#${containerId} .list-item`).nth(index).locator('button:has-text("▼"), button:has-text("Move Down")').first().click(); +} + +/** Switch an application card sub-tab (Info, Extensions, Visual Assets). */ +export async function switchAppSubTab(frame: FrameLocator, appIndex: number, subTabName: string): Promise { + const card = frame.locator('.app-card').nth(appIndex); + await card.locator(`.app-sub-tab[data-subtab="${subTabName.toLowerCase().replace(/ /g, '-')}"]`).click(); +} diff --git a/src/winapp-VSC/src/test/e2e/identity-tab.spec.ts b/src/winapp-VSC/src/test/e2e/identity-tab.spec.ts new file mode 100644 index 00000000..1ba0d2cf --- /dev/null +++ b/src/winapp-VSC/src/test/e2e/identity-tab.spec.ts @@ -0,0 +1,200 @@ +/** + * E2E tests: Identity tab – fields, validation, optional Resource ID, Phone Identity. + */ + +import { test, expect, type FrameLocator } from '@playwright/test'; +import { + switchTab, + getInputValue, + setInputValue, + getValidationMessage, + hasErrorClass, + selectCustomValue, + getCustomSelectValue, + clickButton, + waitForDebounce, + readManifestXml, + type VSCodeTestContext, +} from './helpers'; +import { ensureEditor, resetManifest } from './shared-context'; + +let ctx: VSCodeTestContext; +let frame: FrameLocator; + +test.beforeAll(async () => { + const shared = await ensureEditor(); + ctx = shared.ctx; + frame = await resetManifest(ctx); + await switchTab(frame, 'identity'); +}); + +// ─── Field population ─────────────────────────────────── + +test('name field is populated from manifest', async () => { + const value = await getInputValue(frame, 'identity-name'); + expect(value).toBe('Microsoft.WinUI3ControlsGallery'); +}); + +test('publisher field is populated from manifest', async () => { + const value = await getInputValue(frame, 'identity-publisher'); + expect(value).toContain('CN=Microsoft Corporation'); +}); + +test('version field is populated from manifest', async () => { + const value = await getInputValue(frame, 'identity-version'); + expect(value).toBe('2.8.0.0'); +}); + +// ─── Editing fields ───────────────────────────────────── + +test('editing name field updates the XML document', async () => { + await setInputValue(frame, 'identity-name', 'com.test.MyNewApp'); + await waitForDebounce(ctx.page); + + // Allow VS Code to apply the edit + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Name="com.test.MyNewApp"'); +}); + +test('editing version field updates the XML document', async () => { + await setInputValue(frame, 'identity-version', '3.0.0.0'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Version="3.0.0.0"'); +}); + +// ─── Validation ───────────────────────────────────────── + +test('clearing name shows validation error', async () => { + await setInputValue(frame, 'identity-name', ''); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(500); + + const msg = await getValidationMessage(frame, 'identity.name'); + expect(msg.length).toBeGreaterThan(0); + expect(await hasErrorClass(frame, 'identity.name')).toBe(true); +}); + +test('entering valid name clears validation error', async () => { + await setInputValue(frame, 'identity-name', 'com.test.ValidApp'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(500); + + expect(await hasErrorClass(frame, 'identity.name')).toBe(false); +}); + +test('invalid version format shows validation error', async () => { + await setInputValue(frame, 'identity-version', 'not-a-version'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(500); + + const msg = await getValidationMessage(frame, 'identity.version'); + expect(msg.length).toBeGreaterThan(0); + expect(await hasErrorClass(frame, 'identity.version')).toBe(true); +}); + +test('valid version clears error', async () => { + await setInputValue(frame, 'identity-version', '1.0.0.0'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(500); + + expect(await hasErrorClass(frame, 'identity.version')).toBe(false); +}); + +// ─── Processor Architecture select ────────────────────── + +test('processor architecture custom select displays current value', async () => { + // The fixture doesn't set ProcessorArchitecture, so it should be empty or default + const val = await getCustomSelectValue(frame, 'arch-select'); + expect(typeof val).toBe('string'); +}); + +test('can change processor architecture', async () => { + await selectCustomValue(frame, 'arch-select', 'x64'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('ProcessorArchitecture="x64"'); +}); + +// ─── Optional Resource ID ─────────────────────────────── + +test('add Resource ID button is visible', async () => { + await expect(frame.locator('#add-identity-resourceid')).toBeVisible(); +}); + +test('clicking Add Resource ID shows the field', async () => { + await clickButton(frame, 'add-identity-resourceid'); + await ctx.page.waitForTimeout(500); + + await expect(frame.locator('#identity-resourceid-group')).toBeVisible(); + await expect(frame.locator('#identity-resourceid')).toBeVisible(); +}); + +test('entering a Resource ID updates the XML', async () => { + await setInputValue(frame, 'identity-resourceid', 'MyResourceId'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('ResourceId="MyResourceId"'); +}); + +// ─── Phone Identity ───────────────────────────────────── + +test('Phone Identity section is visible (fixture has PhoneIdentity)', async () => { + // The winui-gallery fixture includes mp:PhoneIdentity + await expect(frame.locator('#phone-identity-section')).toBeVisible(); +}); + +test('Phone Identity fields are populated', async () => { + const productId = await getInputValue(frame, 'phone-product-id'); + expect(productId).toBe('863667e0-667a-4bb4-ac52-c59656c7333a'); + + const publisherId = await getInputValue(frame, 'phone-publisher-id'); + expect(publisherId).toBe('00000000-0000-0000-0000-000000000000'); +}); + +test('editing Phone Identity updates the XML', async () => { + await setInputValue(frame, 'phone-product-id', '11111111-2222-3333-4444-555555555555'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('PhoneProductId="11111111-2222-3333-4444-555555555555"'); +}); + +test('remove Phone Identity button removes the section', async () => { + await clickButton(frame, 'remove-phone-identity-btn'); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).not.toContain('PhoneIdentity'); +}); + +// ─── Regression: issue #426 (paste same value) ────────── + +test('setting name to its current value does not corrupt the XML', async () => { + // First set a known value + await setInputValue(frame, 'identity-name', 'com.test.SameValue'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + // Now "paste" the same value again (simulates user selecting all + paste) + await setInputValue(frame, 'identity-name', 'com.test.SameValue'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + // Should have exactly one Name attribute, not a duplicate + const nameMatches = xml.match(/Name="com\.test\.SameValue"/g) || []; + expect(nameMatches.length).toBe(1); + // XML should still parse (no "Attribute Name redefined" error) + expect(xml).toContain(' { + const textarea = frame.locator('#props-description'); + // The field may or may not exist depending on fixture, but should be present in the DOM + const count = await textarea.count(); + if (count > 0) { + await textarea.fill('A test description'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('A test description'); + } +}); + +// ─── Optional Properties fields ───────────────────────── + +test('can add Auto Update App Installer URI', async () => { + const addBtn = frame.locator('#add-props-autoupdate'); + await expect(addBtn).toBeVisible(); + await addBtn.click(); + await ctx.page.waitForTimeout(500); + + // The field group should now be visible + const group = frame.locator('#props-autoupdate-group'); + await expect(group).toBeVisible(); + + // Fill in a URI and verify it persists + const input = group.locator('input[data-field-name="autoUpdateUri"]'); + await input.fill('https://example.com/appinstaller'); + await input.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('https://example.com/appinstaller'); +}); + +test('can remove Auto Update App Installer URI', async () => { + const group = frame.locator('#props-autoupdate-group'); + const removeBtn = group.locator('.btn-remove-field'); + await removeBtn.click(); + await ctx.page.waitForTimeout(500); + + // The add button should be visible again + await expect(frame.locator('#add-props-autoupdate')).toBeVisible(); +}); + +test('can add Package Integrity Content Enforcement', async () => { + const addBtn = frame.locator('#add-props-pkgintegrity'); + await expect(addBtn).toBeVisible(); + await addBtn.click(); + await ctx.page.waitForTimeout(500); + + const group = frame.locator('#props-pkgintegrity-group'); + await expect(group).toBeVisible(); + + // It should show a select with "default" value + const trigger = group.locator('.custom-select-trigger'); + const value = await trigger.textContent(); + expect(value?.trim()).toBe('default'); +}); + +test('can remove Package Integrity Content Enforcement', async () => { + const group = frame.locator('#props-pkgintegrity-group'); + const removeBtn = group.locator('.btn-remove-field'); + await removeBtn.click(); + await ctx.page.waitForTimeout(500); + + await expect(frame.locator('#add-props-pkgintegrity')).toBeVisible(); +}); + +test('can add Update While In Use', async () => { + const addBtn = frame.locator('#add-props-updatewhileinuse'); + await expect(addBtn).toBeVisible(); + await addBtn.click(); + await ctx.page.waitForTimeout(500); + + const group = frame.locator('#props-updatewhileinuse-group'); + await expect(group).toBeVisible(); + + // It should show a select with "defer" value + const trigger = group.locator('.custom-select-trigger'); + const value = await trigger.textContent(); + expect(value?.trim()).toBe('defer'); +}); + +test('can remove Update While In Use', async () => { + const group = frame.locator('#props-updatewhileinuse-group'); + const removeBtn = group.locator('.btn-remove-field'); + await removeBtn.click(); + await ctx.page.waitForTimeout(500); + + await expect(frame.locator('#add-props-updatewhileinuse')).toBeVisible(); +}); diff --git a/src/winapp-VSC/src/test/e2e/push-notifications-fixture.spec.ts b/src/winapp-VSC/src/test/e2e/push-notifications-fixture.spec.ts new file mode 100644 index 00000000..d88801de --- /dev/null +++ b/src/winapp-VSC/src/test/e2e/push-notifications-fixture.spec.ts @@ -0,0 +1,96 @@ +/** + * E2E tests: Push Notifications sample fixture. + * Verifies the editor correctly loads and displays a manifest with + * COM extensions, protocol extensions, and specific capabilities. + */ + +import { test, expect, type FrameLocator } from '@playwright/test'; +import { + switchTab, + getInputValue, + isCapabilityChecked, + countListItems, + readManifestXml, + setInputValue, + waitForDebounce, + type VSCodeTestContext, +} from './helpers'; +import { ensureEditor, resetManifest } from './shared-context'; + +const FIXTURE = 'push-notifications-sample.appxmanifest'; + +let ctx: VSCodeTestContext; +let frame: FrameLocator; + +test.beforeAll(async () => { + const shared = await ensureEditor(); + ctx = shared.ctx; + frame = await resetManifest(ctx, FIXTURE); +}); + +// ─── Identity ─────────────────────────────────────────── + +test('identity fields are populated correctly', async () => { + await switchTab(frame, 'identity'); + expect(await getInputValue(frame, 'identity-name')).toBe('PushNotificationsSample'); + expect(await getInputValue(frame, 'identity-publisher')).toBe('CN=Microsoft'); + expect(await getInputValue(frame, 'identity-version')).toBe('1.0.0.0'); +}); + +// ─── Properties ───────────────────────────────────────── + +test('properties fields are populated correctly', async () => { + await switchTab(frame, 'properties'); + expect(await getInputValue(frame, 'props-displayname')).toBe('Push Notifications Sample'); + expect(await getInputValue(frame, 'props-pubdisplayname')).toBe('Microsoft Corporation'); + expect(await getInputValue(frame, 'props-logo')).toBe('Images\\StoreLogo.png'); +}); + +// ─── Dependencies ─────────────────────────────────────── + +test('has one target device family (Windows.Universal)', async () => { + await switchTab(frame, 'dependencies'); + const count = await countListItems(frame, 'target-device-families'); + expect(count).toBe(1); + const title = await frame.locator('#target-device-families .list-item').first().locator('.item-title').textContent(); + expect(title).toContain('Windows.Universal'); +}); + +// ─── Applications ─────────────────────────────────────── + +test('application card is present with correct id', async () => { + await switchTab(frame, 'applications'); + const cards = frame.locator('.app-card'); + expect(await cards.count()).toBe(1); + const idInput = cards.first().locator('input[data-field-name="id"]'); + expect(await idInput.inputValue()).toBe('App'); +}); + +test('application has extensions', async () => { + const card = frame.locator('.app-card').first(); + // The extensions sub-tab content should list COM and protocol extensions + const extItems = card.locator('.ext-item, [data-ext-index]'); + const count = await extItems.count(); + expect(count).toBeGreaterThanOrEqual(0); +}); + +// ─── Capabilities ─────────────────────────────────────── + +test('internetClient capability is checked', async () => { + await switchTab(frame, 'capabilities'); + expect(await isCapabilityChecked(frame, 'internetClient')).toBe(true); +}); + +test('runFullTrust restricted capability is checked', async () => { + expect(await isCapabilityChecked(frame, 'rescap:runFullTrust')).toBe(true); +}); + +// ─── Edit round-trip ──────────────────────────────────── + +test('editing identity name persists to XML', async () => { + await switchTab(frame, 'identity'); + await setInputValue(frame, 'identity-name', 'PushNotificationsEdited'); + await waitForDebounce(ctx.page); + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Name="PushNotificationsEdited"'); +}); diff --git a/src/winapp-VSC/src/test/e2e/resources-tab.spec.ts b/src/winapp-VSC/src/test/e2e/resources-tab.spec.ts new file mode 100644 index 00000000..b4534c1e --- /dev/null +++ b/src/winapp-VSC/src/test/e2e/resources-tab.spec.ts @@ -0,0 +1,103 @@ +/** + * E2E tests: Resources tab – add, edit, move, and remove resources. + */ + +import { test, expect, type FrameLocator } from '@playwright/test'; +import { + switchTab, + clickButton, + countListItems, + waitForDebounce, + readManifestXml, + type VSCodeTestContext, +} from './helpers'; +import { ensureEditor, resetManifest } from './shared-context'; + +let ctx: VSCodeTestContext; +let frame: FrameLocator; + +test.beforeAll(async () => { + const shared = await ensureEditor(); + ctx = shared.ctx; + frame = await resetManifest(ctx); + await switchTab(frame, 'resources'); +}); + +// ─── Initial state ────────────────────────────────────── + +test('shows existing resources from fixture', async () => { + const count = await countListItems(frame, 'resources-list'); + expect(count).toBeGreaterThanOrEqual(1); +}); + +test('resource language field is populated', async () => { + const firstItem = frame.locator('#resources-list .list-item').first(); + const langInput = firstItem.locator('input[data-field-name="language"]'); + const val = await langInput.inputValue(); + expect(val).toBe('x-generate'); +}); + +// ─── Add resource ─────────────────────────────────────── + +test('add resource button is visible', async () => { + await expect(frame.locator('#add-resource-btn')).toBeVisible(); +}); + +test('can add a new resource', async () => { + const initial = await countListItems(frame, 'resources-list'); + await clickButton(frame, 'add-resource-btn'); + await ctx.page.waitForTimeout(1_000); + + const updated = await countListItems(frame, 'resources-list'); + expect(updated).toBe(initial + 1); +}); + +// ─── Edit resource ────────────────────────────────────── + +test('can edit resource language', async () => { + const lastItem = frame.locator('#resources-list .list-item').last(); + const langInput = lastItem.locator('input[data-field-name="language"]'); + await langInput.fill('en-US'); + await langInput.dispatchEvent('input'); + await waitForDebounce(ctx.page); + await ctx.page.waitForTimeout(1_000); + + const xml = await readManifestXml(ctx.page, ctx.workspacePath); + expect(xml).toContain('Language="en-US"'); +}); + +// ─── Move resources ───────────────────────────────────── + +test('can move resource down and up', async () => { + // Need at least 2 resources; we added one above + const count = await countListItems(frame, 'resources-list'); + if (count >= 2) { + // Get first resource's language before move + const firstItem = frame.locator('#resources-list .list-item').first(); + const langBefore = await firstItem.locator('input[data-field-name="language"]').inputValue(); + + // Move first item down + const moveDownBtn = firstItem.locator('button:has-text("▼"), button[title*="down"], .btn-move-down').first(); + if (await moveDownBtn.count() > 0) { + await moveDownBtn.click(); + await ctx.page.waitForTimeout(1_000); + + // After moving down, the original first item should now be second + const newFirst = frame.locator('#resources-list .list-item').first(); + const langAfter = await newFirst.locator('input[data-field-name="language"]').inputValue(); + expect(langAfter).not.toBe(langBefore); + } + } +}); + +// ─── Remove resource ──────────────────────────────────── + +test('can remove a resource', async () => { + const initial = await countListItems(frame, 'resources-list'); + const lastItem = frame.locator('#resources-list .list-item').last(); + await lastItem.locator('button:has-text("✕"), .btn-remove-section').first().click(); + await ctx.page.waitForTimeout(1_000); + + const updated = await countListItems(frame, 'resources-list'); + expect(updated).toBe(initial - 1); +}); diff --git a/src/winapp-VSC/src/test/e2e/shared-context.ts b/src/winapp-VSC/src/test/e2e/shared-context.ts new file mode 100644 index 00000000..cc052126 --- /dev/null +++ b/src/winapp-VSC/src/test/e2e/shared-context.ts @@ -0,0 +1,60 @@ +/** + * Singleton VS Code instance shared across all E2E spec files. + * Since Playwright runs with workers:1, all specs execute in the same process. + */ +import { type FrameLocator } from '@playwright/test'; +import { + createTempWorkspace, + launchVSCode, + openManifestEditor, + getWebviewFrame, + teardown, + type VSCodeTestContext, +} from './helpers'; +import * as fs from 'fs'; +import * as path from 'path'; + +let sharedCtx: VSCodeTestContext | null = null; +let sharedFrame: FrameLocator | null = null; +const FIXTURE_NAME = 'winui-gallery.appxmanifest'; +const FIXTURES_DIR = path.resolve(__dirname, '..', 'fixtures'); + +/** + * Returns the shared VS Code instance, launching it on first call. + * Subsequent calls return the existing instance. + */ +export async function ensureEditor(): Promise<{ ctx: VSCodeTestContext; frame: FrameLocator }> { + if (sharedCtx && sharedFrame) { + return { ctx: sharedCtx, frame: sharedFrame }; + } + sharedCtx = await launchVSCode(createTempWorkspace(FIXTURE_NAME)); + sharedFrame = await openManifestEditor(sharedCtx.page); + return { ctx: sharedCtx, frame: sharedFrame }; +} + +/** + * Resets the manifest file to the given fixture (or the default one). + * The editor auto-reloads when the file changes on disk. + * Returns the (possibly refreshed) webview frame. + */ +export async function resetManifest(ctx: VSCodeTestContext, fixtureName: string = FIXTURE_NAME): Promise { + const src = path.join(FIXTURES_DIR, fixtureName); + const dest = path.join(ctx.workspacePath, 'AppxManifest.xml'); + fs.copyFileSync(src, dest); + // Give the editor time to detect the file change and reload + await ctx.page.waitForTimeout(2_000); + // Re-acquire the webview frame (it may have reloaded with new content) + sharedFrame = await getWebviewFrame(ctx.page); + return sharedFrame; +} + +/** + * Tears down the shared VS Code instance. Called from globalTeardown. + */ +export async function closeSharedEditor(): Promise { + if (sharedCtx) { + await teardown(sharedCtx); + sharedCtx = null; + sharedFrame = null; + } +} diff --git a/src/winapp-VSC/src/test/extension-field-validator.test.ts b/src/winapp-VSC/src/test/extension-field-validator.test.ts new file mode 100644 index 00000000..e473f981 --- /dev/null +++ b/src/winapp-VSC/src/test/extension-field-validator.test.ts @@ -0,0 +1,215 @@ +/** + * Unit tests for validateExtensionField — L4 PR review finding. + * Tests all 10 field-specific validation branches in manifest-validator.ts. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { validateExtensionField } from '../manifest-editor/manifest-validator'; + +describe('validateExtensionField', () => { + + // ─── Required field checks ───────────────────────────────── + + describe('required field handling', () => { + it('returns error when required field is empty', () => { + const result = validateExtensionField('Protocol.Name', '', true); + assert.equal(result?.level, 'error'); + assert.ok(result?.message.includes('required')); + }); + + it('returns null when optional field is empty', () => { + assert.equal(validateExtensionField('Protocol.Name', '', false), null); + }); + + it('returns null for unknown field with valid value', () => { + assert.equal(validateExtensionField('SomeUnknown.Field', 'anything', false), null); + }); + }); + + // ─── GUID fields ─────────────────────────────────────────── + + describe('Class.Id (GUID validation)', () => { + it('accepts valid GUID with braces', () => { + assert.equal(validateExtensionField('Class.Id', '{12345678-1234-1234-1234-123456789012}', false), null); + }); + + it('accepts valid GUID without braces', () => { + assert.equal(validateExtensionField('Class.Id', '12345678-1234-1234-1234-123456789012', false), null); + }); + + it('rejects invalid GUID', () => { + const result = validateExtensionField('Class.Id', 'not-a-guid', false); + assert.equal(result?.level, 'error'); + assert.ok(result?.message.includes('GUID')); + }); + }); + + describe('ToastNotificationActivation.ToastActivatorCLSID', () => { + it('accepts valid GUID', () => { + assert.equal(validateExtensionField('ToastNotificationActivation.ToastActivatorCLSID', '{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}', false), null); + }); + + it('rejects invalid GUID', () => { + const result = validateExtensionField('ToastNotificationActivation.ToastActivatorCLSID', 'bad', false); + assert.equal(result?.level, 'error'); + }); + }); + + // ─── ExecutionAlias.Alias ────────────────────────────────── + + describe('ExecutionAlias.Alias', () => { + it('accepts valid alias ending with .exe', () => { + assert.equal(validateExtensionField('ExecutionAlias.Alias', 'myapp.exe', false), null); + }); + + it('rejects alias not ending with .exe', () => { + const result = validateExtensionField('ExecutionAlias.Alias', 'myapp', false); + assert.equal(result?.level, 'error'); + assert.ok(result?.message.includes('.exe')); + }); + + it('rejects alias with path separators', () => { + const result = validateExtensionField('ExecutionAlias.Alias', 'path\\app.exe', false); + assert.equal(result?.level, 'error'); + assert.ok(result?.message.includes('special characters')); + }); + + it('rejects alias with special characters', () => { + const result = validateExtensionField('ExecutionAlias.Alias', 'my*app.exe', false); + assert.equal(result?.level, 'error'); + }); + }); + + // ─── Protocol.Name ───────────────────────────────────────── + + describe('Protocol.Name', () => { + it('accepts valid protocol name', () => { + assert.equal(validateExtensionField('Protocol.Name', 'myapp', false), null); + }); + + it('accepts protocol with dots, plus, hyphen', () => { + assert.equal(validateExtensionField('Protocol.Name', 'my.app+v2-beta', false), null); + }); + + it('rejects protocol starting with digit', () => { + const result = validateExtensionField('Protocol.Name', '1protocol', false); + assert.equal(result?.level, 'error'); + assert.ok(result?.message.includes('lowercase letter')); + }); + + it('rejects uppercase protocol name', () => { + const result = validateExtensionField('Protocol.Name', 'MyApp', false); + assert.equal(result?.level, 'error'); + }); + }); + + // ─── FileType ────────────────────────────────────────────── + + describe('FileType', () => { + it('accepts valid file extension', () => { + assert.equal(validateExtensionField('FileType', '.txt', false), null); + }); + + it('rejects extension without leading dot', () => { + const result = validateExtensionField('FileType', 'txt', false); + assert.equal(result?.level, 'error'); + assert.ok(result?.message.includes('.')); + }); + + it('rejects extension with special characters', () => { + const result = validateExtensionField('FileType', '.tx-t', false); + assert.equal(result?.level, 'error'); + }); + }); + + // ─── FileTypeAssociation.Name ────────────────────────────── + + describe('FileTypeAssociation.Name', () => { + it('accepts valid name', () => { + assert.equal(validateExtensionField('FileTypeAssociation.Name', 'myfiletype', false), null); + }); + + it('accepts name with dots and digits', () => { + assert.equal(validateExtensionField('FileTypeAssociation.Name', 'my.file.type1', false), null); + }); + + it('rejects name with special characters', () => { + const result = validateExtensionField('FileTypeAssociation.Name', 'my-file', false); + assert.equal(result?.level, 'error'); + assert.ok(result?.message.includes('letters, digits')); + }); + }); + + // ─── StartupTask.Enabled ─────────────────────────────────── + + describe('StartupTask.Enabled', () => { + it('accepts "true"', () => { + assert.equal(validateExtensionField('StartupTask.Enabled', 'true', false), null); + }); + + it('accepts "false"', () => { + assert.equal(validateExtensionField('StartupTask.Enabled', 'false', false), null); + }); + + it('rejects other values', () => { + const result = validateExtensionField('StartupTask.Enabled', 'yes', false); + assert.equal(result?.level, 'error'); + assert.ok(result?.message.includes('"true" or "false"')); + }); + }); + + // ─── ExeServer.Executable (warning) ──────────────────────── + + describe('ExeServer.Executable', () => { + it('accepts .exe path', () => { + assert.equal(validateExtensionField('ExeServer.Executable', 'myserver.exe', false), null); + }); + + it('accepts .dll path', () => { + assert.equal(validateExtensionField('ExeServer.Executable', 'mylib.dll', false), null); + }); + + it('warns for non .exe/.dll path', () => { + const result = validateExtensionField('ExeServer.Executable', 'myserver.bat', false); + assert.equal(result?.level, 'warning'); + assert.ok(result?.message.includes('.exe or .dll')); + }); + }); + + // ─── Task.Type (warning) ─────────────────────────────────── + + describe('Task.Type', () => { + it('accepts known type "timer"', () => { + assert.equal(validateExtensionField('Task.Type', 'timer', false), null); + }); + + it('accepts known type "pushNotification"', () => { + assert.equal(validateExtensionField('Task.Type', 'pushNotification', false), null); + }); + + it('warns for unknown type', () => { + const result = validateExtensionField('Task.Type', 'unknownType', false); + assert.equal(result?.level, 'warning'); + assert.ok(result?.message.includes('Common values')); + }); + }); + + // ─── AppService.Name (warning) ───────────────────────────── + + describe('AppService.Name', () => { + it('accepts valid reverse-domain name', () => { + assert.equal(validateExtensionField('AppService.Name', 'com.contoso.myservice', false), null); + }); + + it('warns for name starting with digit', () => { + const result = validateExtensionField('AppService.Name', '1service', false); + assert.equal(result?.level, 'warning'); + assert.ok(result?.message.includes('reverse-domain')); + }); + + it('warns for name with hyphens', () => { + const result = validateExtensionField('AppService.Name', 'my-service', false); + assert.equal(result?.level, 'warning'); + }); + }); +}); diff --git a/src/winapp-VSC/src/test/extension-templates.test.ts b/src/winapp-VSC/src/test/extension-templates.test.ts new file mode 100644 index 00000000..72e232c4 --- /dev/null +++ b/src/winapp-VSC/src/test/extension-templates.test.ts @@ -0,0 +1,409 @@ +/** + * Unit tests for extension templates and real-world manifest parsing. + * Verifies that each EXTENSION_TEMPLATES entry can be inserted into a manifest + * via addExtension() and then round-trips through parseManifest() correctly. + * Also tests parsing of real-world appxmanifest fixtures from WinUI Gallery, + * WindowsAppSDK-Samples, and other Microsoft sample apps. + * + * Run: npx tsx --test src/test/extension-templates.test.ts + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { addExtension, parseManifest } from '../manifest-editor/manifest-parser'; +import { EXTENSION_TEMPLATES } from '../manifest-editor/manifest-types'; +import { validateManifest } from '../manifest-editor/manifest-validator'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +const FIXTURES_DIR = join(__dirname, 'fixtures'); + +function loadFixture(name: string): string { + return readFileSync(join(FIXTURES_DIR, name), 'utf-8'); +} + +function allFixtureFiles(): string[] { + return readdirSync(FIXTURES_DIR).filter(f => f.endsWith('.appxmanifest') && f !== 'edge-cases.appxmanifest'); +} + +/** Minimal valid AppxManifest.xml with one Application and no Extensions. */ +const BASE_MANIFEST = ` + + + + + + TestApp + Test + Assets\\StoreLogo.png + + + + + + + + + + + + + + + +`; + +// ─── Extension template tests (against BASE_MANIFEST) ────────────────────── + +describe('Extension Templates', () => { + for (const template of EXTENSION_TEMPLATES) { + it(`should insert "${template.label}" (${template.category}) into manifest`, () => { + const result = addExtension(BASE_MANIFEST, 0, template.xml); + + assert.notEqual(result, BASE_MANIFEST, 'addExtension should modify the manifest'); + + assert.ok( + result.includes(`Category="${template.category}"`), + `Result should contain Category="${template.category}"` + ); + + const parsed = parseManifest(result); + assert.ok(parsed, 'parseManifest should return a result'); + assert.equal(parsed.applications.length, 1, 'Should still have 1 application'); + + assert.ok( + parsed.applications[0].extensions.length >= 1, + `Application should have at least 1 extension after adding "${template.label}"` + ); + + const addedExt = parsed.applications[0].extensions.find( + (ext: string) => ext.includes(`Category="${template.category}"`) + ); + assert.ok(addedExt, `Should find an extension with Category="${template.category}" in parsed extensions`); + }); + + it(`should add required namespace declarations for "${template.label}"`, () => { + const result = addExtension(BASE_MANIFEST, 0, template.xml); + + const prefixToNs: Record = { + 'com:': 'xmlns:com=', + 'uap3:': 'xmlns:uap3=', + 'uap5:': 'xmlns:uap5=', + 'desktop:': 'xmlns:desktop=', + }; + + for (const [prefix, nsDecl] of Object.entries(prefixToNs)) { + if (template.xml.includes(prefix)) { + assert.ok( + result.includes(nsDecl), + `"${template.label}" uses ${prefix} — result should include ${nsDecl}` + ); + } + } + }); + } + + it('should insert multiple different extensions sequentially', () => { + let manifest = BASE_MANIFEST; + + for (const template of EXTENSION_TEMPLATES) { + manifest = addExtension(manifest, 0, template.xml); + } + + const parsed = parseManifest(manifest); + assert.equal( + parsed.applications[0].extensions.length, + EXTENSION_TEMPLATES.length, + `Should have ${EXTENSION_TEMPLATES.length} extensions after adding all templates` + ); + }); + + it('should preserve existing manifest content when adding extensions', () => { + const template = EXTENSION_TEMPLATES[0]; + const result = addExtension(BASE_MANIFEST, 0, template.xml); + const parsed = parseManifest(result); + + assert.equal(parsed.identity.name, 'TestApp'); + assert.equal(parsed.identity.publisher, 'CN=Test'); + assert.equal(parsed.identity.version, '1.0.0.0'); + assert.equal(parsed.properties.displayName, 'TestApp'); + assert.equal(parsed.applications[0].id, 'App'); + assert.equal(parsed.applications[0].executable, 'TestApp.exe'); + assert.equal(parsed.applications[0].visualElements.displayName, 'TestApp'); + }); +}); + +// ─── Real-world fixture tests ─────────────────────────────────────────────── + +describe('Real-world manifest fixtures', () => { + const fixtures = allFixtureFiles(); + + for (const filename of fixtures) { + describe(filename, () => { + const xml = loadFixture(filename); + + it('should parse without errors', () => { + const parsed = parseManifest(xml); + assert.ok(parsed, 'parseManifest should return a result'); + assert.ok(parsed.identity.name, 'Should have an identity name'); + assert.ok(parsed.identity.publisher, 'Should have an identity publisher'); + assert.ok(parsed.identity.version, 'Should have an identity version'); + assert.ok(parsed.properties.displayName, 'Should have a display name'); + assert.ok(parsed.applications.length >= 1, 'Should have at least 1 application'); + }); + + it('should parse visual elements correctly', () => { + const parsed = parseManifest(xml); + for (const app of parsed.applications) { + assert.ok(app.visualElements.displayName, 'Visual elements should have a display name'); + assert.ok(app.visualElements.square150x150Logo, 'Visual elements should have Square150x150Logo'); + assert.ok(app.visualElements.square44x44Logo, 'Visual elements should have Square44x44Logo'); + } + }); + + it('should parse dependencies', () => { + const parsed = parseManifest(xml); + assert.ok( + parsed.dependencies.targetDeviceFamilies.length >= 1, + 'Should have at least 1 target device family' + ); + for (const family of parsed.dependencies.targetDeviceFamilies) { + assert.ok(family.name, 'Device family should have a name'); + assert.ok(family.minVersion, 'Device family should have a minVersion'); + assert.ok(family.maxVersionTested, 'Device family should have a maxVersionTested'); + } + }); + + it('should parse capabilities', () => { + const parsed = parseManifest(xml); + assert.ok(parsed.capabilities.length >= 1, 'Should have at least 1 capability'); + }); + + it('should be able to add every extension template', () => { + for (const template of EXTENSION_TEMPLATES) { + const result = addExtension(xml, 0, template.xml); + assert.notEqual(result, xml, `addExtension("${template.label}") should modify the manifest`); + + const parsed = parseManifest(result); + const found = parsed.applications[0].extensions.find( + (ext: string) => ext.includes(`Category="${template.category}"`) + ); + assert.ok(found, `Should find "${template.label}" extension after adding to ${filename}`); + } + }); + + it('should produce no fatal validation errors for existing content', () => { + const parsed = parseManifest(xml); + const errors = validateManifest(parsed); + // Only check for errors that indicate our parser is broken, not + // validation warnings about the fixture content itself. + // The $targetnametoken$ placeholders in sample manifests will trigger + // "must be an .exe file" validation — that's expected for templates. + const parserErrors = errors.filter(e => + e.severity === 'error' && + !e.message.includes('.exe') && + !e.message.includes('PNG') && + !e.message.includes('BCP-47') && + !e.message.includes('Image path') && + // Sample fixtures may have empty/placeholder dependency fields + !e.field.startsWith('dependencies.') + ); + assert.equal( + parserErrors.length, 0, + `Unexpected validation errors: ${parserErrors.map(e => `${e.field}: ${e.message}`).join(', ')}` + ); + }); + }); + } +}); + +// ─── Fixture-specific assertions ──────────────────────────────────────────── + +describe('Fixture-specific parsing', () => { + it('winui-gallery: should parse protocol and appUriHandler extensions', () => { + const parsed = parseManifest(loadFixture('winui-gallery.appxmanifest')); + const app = parsed.applications[0]; + assert.ok(app.extensions.length >= 2, 'Should have at least 2 extensions'); + assert.ok(app.extensions.some((e: string) => e.includes('windows.protocol')), 'Should have protocol extension'); + assert.ok(app.extensions.some((e: string) => e.includes('windows.appUriHandler')), 'Should have appUriHandler extension'); + assert.ok(app.visualElements.square310x310Logo, 'Should have Square310x310Logo'); + assert.ok(app.visualElements.square71x71Logo, 'Should have Square71x71Logo'); + assert.ok(app.visualElements.splashScreenImage, 'Should have SplashScreen'); + }); + + it('activation-sample: should parse fileTypeAssociation, protocol, and startupTask extensions', () => { + const parsed = parseManifest(loadFixture('winui-gallery.appxmanifest')); + const exts = parsed.applications[0].extensions; + assert.ok(exts.some((e: string) => e.includes('windows.fileTypeAssociation')), 'Should have fileTypeAssociation'); + assert.ok(exts.some((e: string) => e.includes('windows.protocol')), 'Should have protocol'); + assert.ok(exts.some((e: string) => e.includes('windows.startupTask')), 'Should have startupTask'); + }); + + it('share-target-sample: should parse shareTarget extension', () => { + const parsed = parseManifest(loadFixture('winui-gallery.appxmanifest')); + const exts = parsed.applications[0].extensions; + assert.ok(exts.some((e: string) => e.includes('windows.shareTarget')), 'Should have shareTarget'); + }); + + it('push-notifications-sample: should parse comServer and protocol extensions', () => { + const parsed = parseManifest(loadFixture('push-notifications-sample.appxmanifest')); + const exts = parsed.applications[0].extensions; + assert.ok(exts.some((e: string) => e.includes('windows.comServer')), 'Should have comServer'); + assert.ok(exts.some((e: string) => e.includes('windows.protocol')), 'Should have protocol'); + assert.ok(parsed.capabilities.includes('internetClient'), 'Should have internetClient capability'); + }); + + it('background-task-sample: should parse backgroundTasks and comServer extensions', () => { + const parsed = parseManifest(loadFixture('background-task-sample.appxmanifest')); + const exts = parsed.applications[0].extensions; + assert.ok(exts.some((e: string) => e.includes('windows.backgroundTasks')), 'Should have backgroundTasks'); + assert.ok(exts.some((e: string) => e.includes('windows.comServer')), 'Should have comServer'); + }); + + it('widgets-sample: should parse comServer and appExtension extensions', () => { + const parsed = parseManifest(loadFixture('widgets-sample.appxmanifest')); + const exts = parsed.applications[0].extensions; + assert.ok(exts.some((e: string) => e.includes('windows.comServer')), 'Should have comServer'); + assert.ok(exts.some((e: string) => e.includes('windows.appExtension')), 'Should have appExtension'); + }); + + it('inline: should parse app with no application-level extensions', () => { + const xml = ` + + + + NoExtApp + Test + Assets\\StoreLogo.png + + + + + + + + + + + + + + +`; + const parsed = parseManifest(xml); + assert.equal(parsed.applications[0].extensions.length, 0, 'Should have no application-level extensions'); + assert.ok(parsed.applications[0].visualElements.wide310x150Logo, 'Should have Wide310x150Logo'); + }); +}); + +// ─── PhoneIdentity Tests ──────────────────────────────────────────────────── + +describe('PhoneIdentity parsing', () => { + it('winui-gallery: should parse PhoneIdentity with correct GUIDs', () => { + const parsed = parseManifest(loadFixture('winui-gallery.appxmanifest')); + assert.ok(parsed.phoneIdentity, 'Should have phoneIdentity'); + assert.equal(parsed.phoneIdentity!.phoneProductId, '863667e0-667a-4bb4-ac52-c59656c7333a'); + assert.equal(parsed.phoneIdentity!.phonePublisherId, '00000000-0000-0000-0000-000000000000'); + }); + + it('custom-controls-cpp: should return null phoneIdentity when not present', () => { + const parsed = parseManifest(loadFixture('widgets-sample.appxmanifest')); + assert.equal(parsed.phoneIdentity, null, 'Should be null when mp:PhoneIdentity is absent'); + }); +}); + +// ─── ShowNameOnTiles Tests ────────────────────────────────────────────────── + +import { setShowNameOnTiles } from '../manifest-editor/manifest-parser'; + +describe('ShowNameOnTiles parsing', () => { + it('winui-gallery: should parse ShowNameOnTiles entries', () => { + const parsed = parseManifest(loadFixture('winui-gallery.appxmanifest')); + const tiles = parsed.applications[0].visualElements.showNameOnTiles; + assert.ok(tiles.length > 0, 'Should have ShowNameOnTiles entries'); + assert.ok(tiles.includes('square150x150Logo'), 'Should include square150x150Logo'); + assert.ok(tiles.includes('wide310x150Logo'), 'Should include wide310x150Logo'); + assert.ok(tiles.includes('square310x310Logo'), 'Should include square310x310Logo'); + }); + + it('custom-controls-cpp: should have empty showNameOnTiles when not present', () => { + const parsed = parseManifest(loadFixture('widgets-sample.appxmanifest')); + assert.deepEqual(parsed.applications[0].visualElements.showNameOnTiles, []); + }); +}); + +describe('setShowNameOnTiles', () => { + it('should add ShowNameOnTiles to manifest with self-closing DefaultTile', () => { + const xml = ` + + + + SelfCloseApp + Test + Assets\\StoreLogo.png + + + + + + + + + + + +`; + const result = setShowNameOnTiles(xml, 0, ['square150x150Logo', 'wide310x150Logo']); + const parsed = parseManifest(result); + const tiles = parsed.applications[0].visualElements.showNameOnTiles; + assert.ok(tiles.includes('square150x150Logo')); + assert.ok(tiles.includes('wide310x150Logo')); + }); + + it('should replace existing ShowNameOnTiles entries', () => { + const xml = loadFixture('winui-gallery.appxmanifest'); + // Replace the 3 existing tiles with just 1 + const result = setShowNameOnTiles(xml, 0, ['wide310x150Logo']); + const parsed = parseManifest(result); + const tiles = parsed.applications[0].visualElements.showNameOnTiles; + assert.equal(tiles.length, 1); + assert.ok(tiles.includes('wide310x150Logo')); + }); + + it('should remove ShowNameOnTiles when given empty array', () => { + const xml = loadFixture('winui-gallery.appxmanifest'); + const result = setShowNameOnTiles(xml, 0, []); + const parsed = parseManifest(result); + assert.deepEqual(parsed.applications[0].visualElements.showNameOnTiles, []); + }); + + it('should preserve the rest of the manifest when modifying ShowNameOnTiles', () => { + const xml = loadFixture('winui-gallery.appxmanifest'); + const beforeParsed = parseManifest(xml); + const result = setShowNameOnTiles(xml, 0, ['square150x150Logo']); + const afterParsed = parseManifest(result); + assert.equal(afterParsed.identity.name, beforeParsed.identity.name); + assert.equal(afterParsed.applications[0].visualElements.displayName, beforeParsed.applications[0].visualElements.displayName); + assert.equal(afterParsed.applications[0].extensions.length, beforeParsed.applications[0].extensions.length); + }); +}); diff --git a/src/winapp-VSC/src/test/fixtures/background-task-sample.appxmanifest b/src/winapp-VSC/src/test/fixtures/background-task-sample.appxmanifest new file mode 100644 index 00000000..1aa85dca --- /dev/null +++ b/src/winapp-VSC/src/test/fixtures/background-task-sample.appxmanifest @@ -0,0 +1,86 @@ + + + + + + + + + + BackgroundTaskBuilder + Microsoft + Assets\StoreLogo + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Microsoft.Windows.ApplicationModel.Background.UniversalBGTask.dll + + + + + + + + + diff --git a/src/winapp-VSC/src/test/fixtures/edge-cases.appxmanifest b/src/winapp-VSC/src/test/fixtures/edge-cases.appxmanifest new file mode 100644 index 00000000..488173f5 --- /dev/null +++ b/src/winapp-VSC/src/test/fixtures/edge-cases.appxmanifest @@ -0,0 +1,143 @@ + + + + + + + + + + + <script>alert('xss')</script> 日本語テストアプリ + + This Is A Very Long Publisher Display Name That Tests How The Editor Handles Extended Text <img src=x onerror=alert(1)> + Assets\A\Very\Deep\Nested\Directory\Structure\StoreLogo.png + chars & "quotes" that use CDATA — تطبيق اختباري и кириллица]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MyComponent.dll + + + + + + MyServer.exe + singleInstance + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/winapp-VSC/src/test/fixtures/push-notifications-sample.appxmanifest b/src/winapp-VSC/src/test/fixtures/push-notifications-sample.appxmanifest new file mode 100644 index 00000000..484c33a0 --- /dev/null +++ b/src/winapp-VSC/src/test/fixtures/push-notifications-sample.appxmanifest @@ -0,0 +1,64 @@ + + + + + + + + Push Notifications Sample + Microsoft Corporation + Images\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + WindowsAppRuntimeTestProtocol + + + + + + + + + + + diff --git a/src/winapp-VSC/src/test/fixtures/widgets-sample.appxmanifest b/src/winapp-VSC/src/test/fixtures/widgets-sample.appxmanifest new file mode 100644 index 00000000..c07d4552 --- /dev/null +++ b/src/winapp-VSC/src/test/fixtures/widgets-sample.appxmanifest @@ -0,0 +1,143 @@ + + + + + + + + SampleWidgetProviderApp + Microsoft Corporation + Images\StoreLogo.png + ddd + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/winapp-VSC/src/test/fixtures/winui-gallery.appxmanifest b/src/winapp-VSC/src/test/fixtures/winui-gallery.appxmanifest new file mode 100644 index 00000000..b40635b5 --- /dev/null +++ b/src/winapp-VSC/src/test/fixtures/winui-gallery.appxmanifest @@ -0,0 +1,72 @@ + + + + + + + WinUI 3 Gallery + Microsoft Corporation + Assets\Tiles\StoreLogo.png + multiple + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WinUI 3 Gallery + + + + + Images\StoreLogo.png + + .foo + + + + + + + + + + + + + + + + + + + + diff --git a/src/winapp-VSC/src/test/manifest-edge-cases.test.ts b/src/winapp-VSC/src/test/manifest-edge-cases.test.ts new file mode 100644 index 00000000..a74144d3 --- /dev/null +++ b/src/winapp-VSC/src/test/manifest-edge-cases.test.ts @@ -0,0 +1,1301 @@ +/** + * Edge-case / adversarial tests for manifest-parser.ts. + * Tests parsing, editing, and round-trip preservation with unusual manifests. + * + * Run: npx tsx --test src/test/manifest-edge-cases.test.ts + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + parseManifest, + applyFieldChange, + addCapability, + removeCapability, + addPackageDependency, + removePackageDependency, + addTargetDeviceFamily, + addMainPackageDependency, + addDriverConstraint, + addOSPackageDependency, + addHostRuntimeDependency, + addExternalDependency, + addResource, + removeResource, + addApplication, + removeApplication, + addExtension, + removeExtension, + addPhoneIdentity, + removePhoneIdentity, + setShowNameOnTiles, + ensureNamespace, + findDirectChildElementBounds, +} from '../manifest-editor/manifest-parser'; + +const FIXTURES_DIR = join(__dirname, 'fixtures'); + +function loadFixture(name: string): string { + return readFileSync(join(FIXTURES_DIR, name), 'utf-8'); +} + +/** Parse, apply a field change, re-parse, and verify the change took effect. */ +function roundTrip(xml: string, section: string, field: string, value: string, index?: number): string { + const result = applyFieldChange(xml, section, field, value, index); + // Must still be parseable + const reparsed = parseManifest(result); + assert.ok(reparsed, 'XML should be parseable after edit'); + return result; +} + +// ─── Inline edge-case XML (formerly separate fixture files) ──────────────────── + +const EDGE_MINIMAL_XML = ` + + + + + + MinimalApp + Minimal + Assets\\StoreLogo.png + + + + + + + + + + + +`; + +const EDGE_NO_APPS_XML = ` + + + + + + NoAppsPackage + Test + Assets\\StoreLogo.png + + + + + + + + + + + + + +`; + +const EDGE_SELF_CLOSING_XML = ` + + + + + + SelfClosingApp + SelfClose + Assets\\StoreLogo.png + + + + + + + + + + + + + + + +`; + +const EDGE_EMPTY_ELEMENTS_XML = ` + + + + + + + + + + + + + + + + + + + + + + + + +`; + +const EDGE_WHITESPACE_XML = ` + + + + + + + + +\t\tWhitespaceApp +\t\tWhitespace Publisher +\t\tAssets\\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +const EDGE_MULTI_APP_XML = ` + + + + + + Multi App Package + MultiApp Corp + Assets\\StoreLogo.png + A package with multiple applications for testing. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +// ═══════════════════════════════════════════════════════════════════════ +// 1. MINIMAL MANIFEST — no Resources, no Capabilities, no PhoneIdentity +// ═══════════════════════════════════════════════════════════════════════ +describe('Edge: Minimal Manifest', () => { + const xml = EDGE_MINIMAL_XML; + + it('should parse with empty capabilities and resources', () => { + const m = parseManifest(xml); + assert.equal(m.capabilities.length, 0); + assert.equal(m.resources.length, 0); + assert.equal(m.phoneIdentity, null); + assert.equal(m.applications.length, 1); + }); + + it('should add a capability when Capabilities section is missing', () => { + const result = addCapability(xml, 'internetClient'); + const m = parseManifest(result); + assert.ok(m.capabilities.includes('internetClient'), 'Capability should be added'); + }); + + it('should add a resource when Resources section is missing', () => { + const result = addResource(xml, { language: 'en-us', scale: '', dxFeatureLevel: '' }); + const m = parseManifest(result); + assert.equal(m.resources.length, 1); + assert.equal(m.resources[0].language, 'en-us'); + }); + + it('should add PhoneIdentity when missing', () => { + const result = addPhoneIdentity(xml); + const m = parseManifest(result); + assert.ok(m.phoneIdentity, 'PhoneIdentity should be added'); + assert.ok(m.phoneIdentity!.phoneProductId, 'Should have a product ID'); + }); + + it('should round-trip identity edit', () => { + const result = roundTrip(xml, 'identity', 'name', 'NewMinimalName'); + assert.ok(result.includes('Name="NewMinimalName"')); + }); + + it('should round-trip properties edit', () => { + const result = roundTrip(xml, 'properties', 'displayName', 'NewDisplayName'); + assert.ok(result.includes('NewDisplayName')); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════ +// 2. NO APPLICATIONS — manifest with zero apps +// ═══════════════════════════════════════════════════════════════════════ +describe('Edge: No Applications', () => { + const xml = EDGE_NO_APPS_XML; + + it('should parse with zero applications', () => { + const m = parseManifest(xml); + assert.equal(m.applications.length, 0); + // Other sections should still parse + assert.equal(m.identity.name, 'NoAppsPackage'); + assert.ok(m.capabilities.length > 0); + }); + + it('should still edit identity fields', () => { + const result = roundTrip(xml, 'identity', 'name', 'StillEditable'); + assert.ok(result.includes('Name="StillEditable"')); + }); + + it('should still edit properties', () => { + const result = roundTrip(xml, 'properties', 'displayName', 'NoAppDisplay'); + assert.ok(result.includes('NoAppDisplay')); + }); + + it('should still add/remove capabilities', () => { + const added = addCapability(xml, 'privateNetworkClientServer'); + const m = parseManifest(added); + assert.ok(m.capabilities.includes('privateNetworkClientServer')); + const removed = removeCapability(added, 'privateNetworkClientServer'); + const m2 = parseManifest(removed); + assert.ok(!m2.capabilities.includes('privateNetworkClientServer')); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════ +// 3. MULTIPLE APPLICATIONS +// ═══════════════════════════════════════════════════════════════════════ +describe('Edge: Multiple Applications', () => { + const xml = EDGE_MULTI_APP_XML; + + it('should parse all 3 applications', () => { + const m = parseManifest(xml); + assert.equal(m.applications.length, 3); + assert.equal(m.applications[0].id, 'MainApp'); + assert.equal(m.applications[1].id, 'HelperApp'); + assert.equal(m.applications[2].id, 'DiagApp'); + }); + + it('should parse extensions per-app correctly', () => { + const m = parseManifest(xml); + assert.equal(m.applications[0].extensions.length, 0, 'MainApp should have no extensions'); + assert.equal(m.applications[1].extensions.length, 1, 'HelperApp should have 1 extension'); + assert.equal(m.applications[2].extensions.length, 0, 'DiagApp should have no extensions'); + }); + + it('should parse AppListEntry=none on third app', () => { + const m = parseManifest(xml); + assert.equal(m.applications[2].visualElements.appListEntry, 'none'); + }); + + it('should edit the second app display name without touching first or third', () => { + const result = applyFieldChange(xml, 'applications', 'visualElements.displayName', 'Modified Helper', 1); + const m = parseManifest(result); + assert.equal(m.applications[0].visualElements.displayName, 'Main Application'); + assert.equal(m.applications[1].visualElements.displayName, 'Modified Helper'); + assert.equal(m.applications[2].visualElements.displayName, 'Diagnostics'); + }); + + it('should edit the third app background color', () => { + const result = applyFieldChange(xml, 'applications', 'visualElements.backgroundColor', '#FF0000', 2); + const m = parseManifest(result); + assert.equal(m.applications[2].visualElements.backgroundColor, '#FF0000'); + assert.equal(m.applications[0].visualElements.backgroundColor, '#1E90FF'); + }); + + it('should add a 4th application', () => { + const result = addApplication(xml); + const m = parseManifest(result); + assert.equal(m.applications.length, 4); + }); + + it('should remove the middle (2nd) application', () => { + const result = removeApplication(xml, 1); + const m = parseManifest(result); + assert.equal(m.applications.length, 2); + assert.equal(m.applications[0].id, 'MainApp'); + assert.equal(m.applications[1].id, 'DiagApp'); + }); + + it('should not remove when only 1 app left', () => { + let result = removeApplication(xml, 0); + result = removeApplication(result, 0); + // After removing 2, should have 1 left and refuse to remove it + const m = parseManifest(result); + assert.equal(m.applications.length, 1); + const result2 = removeApplication(result, 0); + const m2 = parseManifest(result2); + assert.equal(m2.applications.length, 1, 'Should not remove last app'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════ +// 4. XML COMMENTS EVERYWHERE +// ═══════════════════════════════════════════════════════════════════════ +describe('Edge: Comments Everywhere', () => { + const xml = loadFixture('edge-cases.appxmanifest'); + + it('should parse correctly despite comments', () => { + const m = parseManifest(xml); + assert.equal(m.identity.name, 'A.Very.Long.Package.Name.That.Pushes.Max.Limit.日本語'); + assert.ok(m.properties.displayName.includes('日本語テストアプリ')); + assert.equal(m.applications.length, 3); + assert.ok(m.capabilities.length > 0); + }); + + it('should NOT be confused by comments mentioning ', () => { + // The comment mentions before the real Dependencies element + // findParentBounds should find the real element, not the comment + const result = addPackageDependency(xml, { + name: 'TestPkg', + minVersion: '1.0.0.0', + publisher: 'CN=Test', + optional: '', + }); + const m = parseManifest(result); + assert.ok( + m.dependencies.packageDependencies.some(d => d.name === 'TestPkg'), + 'Should add package dependency despite confusing comments' + ); + }); + + it('should round-trip identity edit despite comments', () => { + const result = roundTrip(xml, 'identity', 'name', 'CommentSafe'); + assert.ok(result.includes('Name="CommentSafe"')); + // Comments should be preserved + assert.ok(result.includes(''; + const bounds = findParentBounds(xml, 'Parent')!; + const children = findDirectChildElementBounds(xml, bounds.contentStart, bounds.contentEnd); + assert.equal(children.length, 1); + }); + + it('skips CDATA sections', () => { + const xml = ']]>'; + const bounds = findParentBounds(xml, 'Parent')!; + const children = findDirectChildElementBounds(xml, bounds.contentStart, bounds.contentEnd); + assert.equal(children.length, 1); + }); +}); + +// --------------------------------------------------------------------------- +// ensureNamespace +// --------------------------------------------------------------------------- +describe('ensureNamespace', () => { + it('returns unchanged when namespace already present', () => { + const xml = '\n'; + const result = ensureNamespace(xml, 'uap', 'http://schemas.microsoft.com/appx/manifest/uap/windows10'); + assert.equal(result, xml); + }); + + it('adds namespace to multiline Package tag', () => { + const xml = '\n'; + const result = ensureNamespace(xml, 'rescap', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities'); + assert.ok(result.includes('xmlns:rescap=')); + }); + + it('adds namespace to single-line Package tag', () => { + const xml = ''; + const result = ensureNamespace(xml, 'uap', 'http://schemas.microsoft.com/appx/manifest/uap/windows10'); + assert.ok(result.includes('xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"')); + }); +}); + +// --------------------------------------------------------------------------- +// swapAdjacentElements +// --------------------------------------------------------------------------- +describe('swapAdjacentElements', () => { + it('swaps two adjacent elements', () => { + const xml = ''; + const a = { start: 6, end: 11 }; // + const b = { start: 11, end: 16 }; // + const result = swapAdjacentElements(xml, a, b); + assert.equal(result, ''); + }); + + it('handles reversed order (a.start > b.start)', () => { + const xml = ''; + const a = { start: 11, end: 16 }; // + const b = { start: 6, end: 11 }; // + const result = swapAdjacentElements(xml, a, b); + assert.equal(result, ''); + }); +}); + +// --------------------------------------------------------------------------- +// detectIndent +// --------------------------------------------------------------------------- +describe('detectIndent', () => { + it('detects indentation of the line at the given position', () => { + const xml = '\n \n'; + // pos of ' { + const xml = ''; + assert.equal(detectIndent(xml, 0), ''); + }); +}); + +// --------------------------------------------------------------------------- +// getCapabilityElementInfo +// --------------------------------------------------------------------------- +describe('getCapabilityElementInfo', () => { + it('handles device: prefix', () => { + const info = getCapabilityElementInfo('device:microphone'); + assert.equal(info.elementName, 'DeviceCapability'); + assert.equal(info.attrName, 'microphone'); + }); + + it('handles ns:name prefix', () => { + const info = getCapabilityElementInfo('uap:appointments'); + assert.equal(info.elementName, 'uap:Capability'); + assert.equal(info.attrName, 'appointments'); + }); + + it('handles custom capability pattern', () => { + const info = getCapabilityElementInfo('company.name.cap_1234567890abc'); + assert.equal(info.elementName, 'uap4:CustomCapability'); + assert.equal(info.attrName, 'company.name.cap_1234567890abc'); + }); + + it('handles plain capability', () => { + const info = getCapabilityElementInfo('internetClient'); + assert.equal(info.elementName, 'Capability'); + assert.equal(info.attrName, 'internetClient'); + }); +}); + +// --------------------------------------------------------------------------- +// buildVisualChildElement +// --------------------------------------------------------------------------- +describe('buildVisualChildElement', () => { + it('builds wide310x150Logo element', () => { + const result = buildVisualChildElement('wide310x150Logo', 'Assets\\Wide.png'); + assert.equal(result, ''); + }); + + it('builds badgeLogo element', () => { + const result = buildVisualChildElement('badgeLogo', 'Assets\\Badge.png'); + assert.equal(result, ''); + }); + + it('builds splashScreenImage element', () => { + const result = buildVisualChildElement('splashScreenImage', 'Assets\\Splash.png'); + assert.equal(result, ''); + }); + + it('returns null for unknown field', () => { + assert.equal(buildVisualChildElement('unknownField', 'value'), null); + }); +}); + +// --------------------------------------------------------------------------- +// insertChildBeforeClose +// --------------------------------------------------------------------------- +describe('insertChildBeforeClose', () => { + it('inserts child element with proper indentation', () => { + const xml = '\n \n '; + const closePos = xml.indexOf(''); + const result = insertChildBeforeClose(xml, closePos, '', ' '); + assert.ok(result.includes(' ')); + assert.ok(result.includes('')); + // Child should appear before the close tag + const newIdx = result.indexOf(''); + const closeIdx = result.indexOf(''); + assert.ok(newIdx < closeIdx); + }); +});