diff --git a/.github/workflows/_integration-tests.yml b/.github/workflows/_integration-tests.yml new file mode 100644 index 0000000..0b825d6 --- /dev/null +++ b/.github/workflows/_integration-tests.yml @@ -0,0 +1,41 @@ +name: Integration Tests + +on: + workflow_call: + inputs: + os: + required: true + type: string + +permissions: + contents: read + +jobs: + integration-tests: + name: Integration Tests (${{ inputs.os }}) + runs-on: ${{ inputs.os }} + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Compile tests + run: npm run compile-tests + + # VS Code integration tests need a display server on Linux + - name: Run extension tests (Linux) + if: inputs.os == 'ubuntu-latest' + uses: coactions/setup-xvfb@v1 + with: + run: npm test + + - name: Run extension tests (Windows/macOS) + if: inputs.os != 'ubuntu-latest' + run: npm test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 238f031..c39c2f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,14 +45,10 @@ jobs: - name: Package (production build) run: npm run package - test: - name: Test (${{ matrix.os }}) + unit-tests: + name: Unit Tests & Coverage + runs-on: ubuntu-latest needs: build - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 @@ -65,16 +61,12 @@ jobs: - name: Install dependencies run: npm ci - - name: Compile tests - run: npm run compile-tests + - name: Run unit tests with coverage + run: npm run test:unit:coverage - # VS Code integration tests need a display server on Linux - - name: Run extension tests (Linux) - if: runner.os == 'Linux' - uses: coactions/setup-xvfb@v1 + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + if: github.repository == 'OpenForgeProject/mageforge-vscode' with: - run: npm test - - - name: Run extension tests (Windows/macOS) - if: runner.os != 'Linux' - run: npm test + files: ./coverage/lcov.info + fail_ci_if_error: false diff --git a/.github/workflows/test-integration-macos.yml b/.github/workflows/test-integration-macos.yml new file mode 100644 index 0000000..fc557a9 --- /dev/null +++ b/.github/workflows/test-integration-macos.yml @@ -0,0 +1,20 @@ +name: Integration Tests (macOS) + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + integration-tests: + uses: ./.github/workflows/_integration-tests.yml + with: + os: macos-latest diff --git a/.github/workflows/test-integration-ubuntu.yml b/.github/workflows/test-integration-ubuntu.yml new file mode 100644 index 0000000..cd968f7 --- /dev/null +++ b/.github/workflows/test-integration-ubuntu.yml @@ -0,0 +1,20 @@ +name: Integration Tests (Ubuntu) + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + integration-tests: + uses: ./.github/workflows/_integration-tests.yml + with: + os: ubuntu-latest diff --git a/.github/workflows/test-integration-windows.yml b/.github/workflows/test-integration-windows.yml new file mode 100644 index 0000000..194ce00 --- /dev/null +++ b/.github/workflows/test-integration-windows.yml @@ -0,0 +1,20 @@ +name: Integration Tests (Windows) + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + integration-tests: + uses: ./.github/workflows/_integration-tests.yml + with: + os: windows-latest diff --git a/.gitignore b/.gitignore index c86c305..ab07ecb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,8 @@ out/ # VS Code test binaries & cache .vscode-test/ +# Test coverage +coverage/ + # Local tooling .DS_Store diff --git a/.vscode-test.mjs b/.vscode-test.mjs index d02aa34..8b75420 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,6 +1,14 @@ import { defineConfig } from '@vscode/test-cli'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mageforge-vscode-test-')); +fs.mkdirSync(path.join(workspaceDir, 'app', 'etc'), { recursive: true }); +fs.writeFileSync(path.join(workspaceDir, 'app', 'etc', 'env.php'), '=18" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -792,6 +820,34 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -820,6 +876,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mock-require": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/mock-require/-/mock-require-3.0.0.tgz", + "integrity": "sha512-TZ/s3ufaF+kEFPx9PqXKNd7OsPJoVsgOd59EA8XQLTRjJCsOdx5sEAOGhyrZ3Os78iaDKyGJWdQWyYWRg1Iyvg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -1101,6 +1164,137 @@ "node": ">=22" } }, + "node_modules/@vscode/test-cli/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/@vscode/test-cli/node_modules/c8": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", + "integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "20 || >=22" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/@vscode/test-cli/node_modules/c8/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@vscode/test-cli/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@vscode/test-cli/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/@vscode/test-cli/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/@vscode/test-cli/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/@vscode/test-cli/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@vscode/test-electron": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-3.1.0.tgz", @@ -1141,6 +1335,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -1197,6 +1404,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1300,9 +1514,9 @@ "license": "ISC" }, "node_modules/c8": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", - "integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", "dev": true, "license": "ISC", "dependencies": { @@ -1315,14 +1529,14 @@ "istanbul-reports": "^3.1.6", "test-exclude": "^8.0.0", "v8-to-istanbul": "^9.0.0", - "yargs": "^17.7.2", + "yargs": "^18.0.0", "yargs-parser": "^21.1.1" }, "bin": { "c8": "bin/c8.js" }, "engines": { - "node": "20 || >=22" + "node": "^20.19.0 || ^22.12.0 || >=23" }, "peerDependencies": { "monocart-coverage-reports": "^2" @@ -1333,103 +1547,6 @@ } } }, - "node_modules/c8/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/c8/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/c8/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/c8/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/c8/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/c8/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/c8/node_modules/yargs": { - "version": "17.7.3", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", - "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -1621,6 +1738,13 @@ "dev": true, "license": "MIT" }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3527,6 +3651,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3668,6 +3799,27 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/mock-require": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mock-require/-/mock-require-3.0.3.tgz", + "integrity": "sha512-lLzfLHcyc10MKQnNUCv7dMcoY/2Qxd6wJfbqCcVk3LDb8An4hF6ohk5AztrvgKhJCqj36uyzi/p5se+tvyD+Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-caller-file": "^1.0.2", + "normalize-path": "^2.1.1" + }, + "engines": { + "node": ">=4.3.0" + } + }, + "node_modules/mock-require/node_modules/get-caller-file": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", + "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", + "dev": true, + "license": "ISC" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3712,6 +3864,19 @@ "semver": "bin/semver" } }, + "node_modules/normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "remove-trailing-separator": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-run-all": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", @@ -4405,6 +4570,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", + "dev": true, + "license": "ISC" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5057,6 +5229,60 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5205,6 +5431,13 @@ "dev": true, "license": "MIT" }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -5473,6 +5706,16 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 6d4486e..aa63cc1 100644 --- a/package.json +++ b/package.json @@ -254,6 +254,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test": "vscode-test", + "test:unit": "mocha --ui tdd --require ts-node/register --require src/test/unit/setup.ts 'src/test/unit/**/*.test.ts'", + "test:unit:coverage": "c8 --reporter=text --reporter=lcov npm run test:unit", "prepare": "husky" }, "lint-staged": { @@ -262,6 +264,7 @@ }, "devDependencies": { "@types/mocha": "^10.0.10", + "@types/mock-require": "^3.0.0", "@types/node": "^24.13.3", "@types/shell-quote": "^1.7.5", "@types/vscode": "^1.125.0", @@ -269,13 +272,16 @@ "@typescript-eslint/parser": "^8.17.0", "@vscode/test-cli": "^0.0.15", "@vscode/test-electron": "^3.1.0", + "c8": "^12.0.0", "esbuild": "^0.28.2", "eslint": "^9.16.0", "eslint-config-prettier": "^10.1.8", "husky": "^9.1.7", "lint-staged": "^17.3.0", + "mock-require": "^3.0.3", "npm-run-all": "^4.1.5", "prettier": "^3.9.6", + "ts-node": "^10.9.2", "typescript": "^5.7.2" }, "overrides": { diff --git a/src/changelogProvider.ts b/src/changelogProvider.ts index ec654ec..80ba179 100644 --- a/src/changelogProvider.ts +++ b/src/changelogProvider.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { isAllowedExternalUrl } from './url'; const CHANGELOG_URL = 'https://github.com/OpenForgeProject/mageforge-vscode/blob/main/CHANGELOG.md'; @@ -41,7 +42,7 @@ export class ChangelogViewProvider { this.panel.webview.html = this.getHtml(this.panel.webview, subtitle); this.panel.webview.onDidReceiveMessage((message: { url?: string }) => { - if (message.url) { + if (message.url && isAllowedExternalUrl(message.url)) { void vscode.env.openExternal(vscode.Uri.parse(message.url)); } }, undefined); diff --git a/src/extension.ts b/src/extension.ts index 38b642d..3561b40 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -68,7 +68,7 @@ export function activate(context: vscode.ExtensionContext) { * Shows an update notification and opens the changelog tab when the extension * version has changed since the last activation. */ -async function showUpdateNotificationIfNeeded( +export async function showUpdateNotificationIfNeeded( context: vscode.ExtensionContext, changelogProvider: ChangelogViewProvider, ): Promise { @@ -97,7 +97,7 @@ async function showUpdateNotificationIfNeeded( * Explorer context menu: override the selected template file in a chosen theme. * Runs: bin/magento mageforge:template:override --theme */ -async function overrideFile( +export async function overrideFile( uri: vscode.Uri | undefined, themesProvider: ThemesProvider, ): Promise { @@ -133,7 +133,7 @@ async function overrideFile( runInTerminal('MageForge: template:override', commandLine, magentoRoot); } -async function runMageforgeCommand( +export async function runMageforgeCommand( cmd: MageforgeCommand, themesProvider: ThemesProvider, item?: ThemeTreeItem, diff --git a/src/magento.ts b/src/magento.ts index cb92d4f..b597884 100644 --- a/src/magento.ts +++ b/src/magento.ts @@ -181,11 +181,13 @@ export function execMageforge( file = 'lando'; finalArgs = ['php', ...baseArgs]; break; - default: + default: { // phpBinary may be a full command like "docker-compose exec php" - const parts = phpBinary.split(/\s+/); + const parts = shellQuote.parse(phpBinary) as string[]; file = parts[0]; finalArgs = [...parts.slice(1), ...baseArgs]; + break; + } } execFile( diff --git a/src/test/README.md b/src/test/README.md new file mode 100644 index 0000000..4559aa9 --- /dev/null +++ b/src/test/README.md @@ -0,0 +1,63 @@ +# MageForge VS Code Extension Tests + +This directory contains both fast unit tests and VS Code integration tests. + +## Test Structure + +``` +src/test/ +├── extension.test.ts # VS Code integration tests +├── unit/ +│ ├── setup.ts # Mocha setup: mocks the vscode API for unit tests +│ ├── changelogProvider.test.ts +│ ├── commandsProvider.test.ts +│ ├── extension.test.ts +│ ├── magento.test.ts +│ ├── themesProvider.test.ts +│ ├── themesProviderClass.test.ts +│ └── welcomeProvider.test.ts +└── README.md # This file +``` + +## Running Tests + +### Unit Tests + +Fast tests that run without starting VS Code. They mock the `vscode` API and +validate pure business logic. + +```bash +npm run test:unit +``` + +With coverage: + +```bash +npm run test:unit:coverage +``` + +### Integration Tests + +Tests that run inside a real VS Code instance. They validate command +registration, view providers, and extension activation. + +```bash +npm test +``` + +This compiles the extension and tests, then launches VS Code with the test +workspace defined in `.vscode-test.mjs`. + +## CI Pipeline + +The GitHub Actions workflow (`.github/workflows/ci.yml`) runs: + +1. **Build & Lint** – type check, formatting check, lint, production build +2. **Unit Tests & Coverage** – `npm run test:unit:coverage` on Ubuntu +3. **Integration Tests** – `npm test` on Ubuntu, Windows, and macOS + +## Writing New Tests + +- Add pure function tests to `src/test/unit/`. +- Add VS Code API dependent tests to `src/test/extension.test.ts`. +- Run `npm run lint` and `npm run format:check` before committing. diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 0418112..ae21052 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -1,15 +1,33 @@ import * as assert from 'assert'; - -// You can import and use all API from the 'vscode' module -// as well as import your extension to test it import * as vscode from 'vscode'; -// import * as myExtension from '../../extension'; -suite('Extension Test Suite', () => { - vscode.window.showInformationMessage('Start all tests.'); +suite('Extension Integration Test Suite', () => { + test('extension is activated', async () => { + const extension = vscode.extensions.getExtension('OpenForgeProject.mageforge'); + assert.ok(extension, 'MageForge extension should be installed'); + + await extension.activate(); + assert.strictEqual(extension.isActive, true, 'MageForge extension should be active'); + }); + + test('registered commands exist', async () => { + const extension = vscode.extensions.getExtension('OpenForgeProject.mageforge'); + assert.ok(extension); + await extension.activate(); + + const commands = await vscode.commands.getCommands(true); + const mageforgeCommands = commands.filter((cmd) => cmd.startsWith('mageforge.')); + + assert.ok(mageforgeCommands.includes('mageforge.theme.build')); + assert.ok(mageforgeCommands.includes('mageforge.theme.watch')); + assert.ok(mageforgeCommands.includes('mageforge.refreshThemes')); + assert.ok(mageforgeCommands.includes('mageforge.showChangelog')); + assert.ok(mageforgeCommands.includes('mageforge.updateMageforge')); + }); - test('Sample test', () => { - assert.strictEqual(-1, [1, 2, 3].indexOf(5)); - assert.strictEqual(-1, [1, 2, 3].indexOf(0)); + test('mageforge views are registered', async () => { + await vscode.commands.executeCommand('mageforge.welcome.focus'); + // If the command does not throw, the view provider is registered. + assert.ok(true); }); }); diff --git a/src/test/unit/changelogProvider.test.ts b/src/test/unit/changelogProvider.test.ts new file mode 100644 index 0000000..c6f9e34 --- /dev/null +++ b/src/test/unit/changelogProvider.test.ts @@ -0,0 +1,132 @@ +import * as assert from 'assert'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import { ChangelogViewProvider } from '../../changelogProvider'; +import { createMockWebview, getLastWebviewPanel } from './setup'; + +function makeTestUrl(path: string): string { + return `https://github.com/${path}`; +} + +suite('changelogProvider.ts unit tests', () => { + let sandbox: string; + + setup(() => { + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'mageforge-changelog-test-')); + }); + + teardown(() => { + fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + function createExtensionWithChangelog(content: string): string { + fs.writeFileSync(path.join(sandbox, 'CHANGELOG.md'), content); + return sandbox; + } + + test('renders changelog HTML with version title', () => { + const extPath = createExtensionWithChangelog( + '# Changelog\n\n## 1.0.0\n\n- Initial release', + ); + const provider = new ChangelogViewProvider({ fsPath: extPath } as import('vscode').Uri); + + const webview = createMockWebview(); + provider.show('v1.0.0'); + + // The panel is created internally; we cannot easily capture it without mocking vscode.window. + // Instead we test the HTML generation through the public API indirectly by creating a panel mock. + const mockPanel = { + webview, + title: '', + reveal: () => undefined, + onDidDispose: () => undefined, + dispose: () => undefined, + }; + + // Force set the private panel to test HTML generation + (provider as any).panel = mockPanel; + webview.html = (provider as any).getHtml(webview, 'v1.0.0'); + + assert.ok(webview.html.includes('v1.0.0')); + assert.ok(webview.html.includes('Initial release')); + assert.ok(webview.html.includes('Latest')); + }); + + test('markdownToHtml converts headings and lists', () => { + const extPath = createExtensionWithChangelog(''); + const provider = new ChangelogViewProvider({ fsPath: extPath } as import('vscode').Uri); + + const html = (provider as any).markdownToHtml( + '## 1.0.0\n\n### Features\n\n- New feature\n- Another feature', + ); + + assert.ok(html.includes('1.0.0')); + assert.ok(html.includes('Features')); + assert.ok(html.includes('New feature')); + assert.ok(html.includes('Another feature')); + }); + + test('markdownToHtml escapes HTML in code blocks', () => { + const extPath = createExtensionWithChangelog(''); + const provider = new ChangelogViewProvider({ fsPath: extPath } as import('vscode').Uri); + + const html = (provider as any).markdownToHtml('```\n\n```'); + + assert.ok(!html.includes('')); + // Content is escaped twice: once globally, then again inside the code block. + assert.ok(html.includes('&lt;script&gt;')); + }); + + test('markdownToHtml preserves links', () => { + const extPath = createExtensionWithChangelog(''); + const provider = new ChangelogViewProvider({ fsPath: extPath } as import('vscode').Uri); + const linkUrl = makeTestUrl('OpenForgeProject/mageforge'); + + const html = (provider as any).markdownToHtml(`[link text](${linkUrl})`); + + assert.ok(html.includes(`link text`)); + }); + + test('shows empty state when changelog is missing', () => { + const provider = new ChangelogViewProvider({ fsPath: sandbox } as import('vscode').Uri); + const webview = createMockWebview(); + const html = (provider as any).getHtml(webview); + + assert.ok(html.includes('Could not load the changelog')); + }); + + test('webview message with url opens external link', async () => { + const extPath = createExtensionWithChangelog('# Changelog'); + const provider = new ChangelogViewProvider({ fsPath: extPath } as import('vscode').Uri); + const testUrl = makeTestUrl('OpenForgeProject/mageforge'); + + provider.show(); + const panel = getLastWebviewPanel(); + assert.ok(panel); + assert.ok(panel!.webview.onDidReceiveMessageHandler); + + panel!.webview.onDidReceiveMessageHandler!({ url: testUrl }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + const env = vscode.env as unknown as { openedExternals: string[] }; + assert.ok(env.openedExternals.includes(testUrl)); + }); + + test('webview message with dangerous url is ignored', async () => { + const extPath = createExtensionWithChangelog('# Changelog'); + const provider = new ChangelogViewProvider({ fsPath: extPath } as import('vscode').Uri); + + provider.show(); + const panel = getLastWebviewPanel(); + assert.ok(panel); + assert.ok(panel!.webview.onDidReceiveMessageHandler); + + panel!.webview.onDidReceiveMessageHandler!({ url: 'javascript:alert(1)' }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + const env = vscode.env as unknown as { openedExternals: string[] }; + assert.strictEqual(env.openedExternals.length, 0); + }); +}); diff --git a/src/test/unit/commandsProvider.test.ts b/src/test/unit/commandsProvider.test.ts new file mode 100644 index 0000000..34c167a --- /dev/null +++ b/src/test/unit/commandsProvider.test.ts @@ -0,0 +1,47 @@ +import * as assert from 'assert'; +import { CommandTreeItem, CommandsProvider, MAGEFORGE_COMMANDS } from '../../commandsProvider'; + +suite('commandsProvider.ts unit tests', () => { + suite('MAGEFORGE_COMMANDS', () => { + test('contains expected commands', () => { + const ids = MAGEFORGE_COMMANDS.map((cmd) => cmd.id); + assert.ok(ids.includes('mageforge.theme.build')); + assert.ok(ids.includes('mageforge.theme.watch')); + assert.ok(ids.includes('mageforge.theme.clean')); + assert.ok(ids.includes('mageforge.system.version')); + }); + + test('theme commands accept themes', () => { + const themeCommands = MAGEFORGE_COMMANDS.filter((cmd) => cmd.acceptsThemes); + const ids = themeCommands.map((cmd) => cmd.id); + assert.deepStrictEqual(ids.sort(), ['mageforge.theme.build', 'mageforge.theme.watch']); + }); + + test('watch command is marked as watch', () => { + const watch = MAGEFORGE_COMMANDS.find((cmd) => cmd.id === 'mageforge.theme.watch'); + assert.strictEqual(watch?.isWatch, true); + }); + }); + + suite('CommandsProvider', () => { + test('returns all commands as tree items', () => { + const provider = new CommandsProvider(); + const children = provider.getChildren(); + + assert.strictEqual(children.length, MAGEFORGE_COMMANDS.length); + }); + }); + + suite('CommandTreeItem', () => { + test('sets label, description and command', () => { + const cmd = MAGEFORGE_COMMANDS[0]; + const item = new CommandTreeItem(cmd); + + assert.strictEqual(item.label, cmd.label); + assert.strictEqual(item.description, cmd.description); + assert.strictEqual(item.command?.command, cmd.id); + assert.strictEqual(item.command?.title, cmd.label); + assert.strictEqual(item.contextValue, 'mageforgeCommand'); + }); + }); +}); diff --git a/src/test/unit/extension.test.ts b/src/test/unit/extension.test.ts new file mode 100644 index 0000000..339f807 --- /dev/null +++ b/src/test/unit/extension.test.ts @@ -0,0 +1,240 @@ +import * as assert from 'assert'; +import mockRequire = require('mock-require'); +import * as vscode from 'vscode'; +import type { MageforgeCommand } from '../../commandsProvider'; +import type { ThemesProvider } from '../../themesProvider'; +import { ThemeTreeItem } from '../../themesProvider'; + +type CapturedTerminal = { name: string; command: string; cwd: string }; +type MockMagento = { + getMagentoRoot: () => string | undefined; + buildCommandLine: (root: string, command: string, args: string[]) => string; + buildComposerUpdateCommand: (root: string, packageName?: string) => string; + runInTerminal: (name: string, command: string, cwd: string) => void; +}; + +function loadExtension(magentoMock: MockMagento) { + mockRequire('../../magento', magentoMock); + return mockRequire.reRequire('../../extension') as typeof import('../../extension'); +} + +function createThemesProvider(themeCodes: string[] = []): ThemesProvider { + return { + getThemeCodes: async () => themeCodes, + } as unknown as ThemesProvider; +} + +suite('extension.ts unit tests', () => { + let terminals: CapturedTerminal[]; + let mockMagento: MockMagento; + + setup(() => { + terminals = []; + mockMagento = { + getMagentoRoot: () => '/magento', + buildCommandLine: (_root, command, args) => + `php bin/magento ${command} ${args.join(' ')}`, + buildComposerUpdateCommand: (_root, packageName) => + `composer update ${packageName ?? 'openforgeproject/mageforge'}`, + runInTerminal: (name, command, cwd) => { + terminals.push({ name, command, cwd }); + }, + }; + }); + + teardown(() => { + mockRequire.stop('../../magento'); + }); + + suite('runMageforgeCommand', () => { + test('runs command with theme from tree item', async () => { + const { runMageforgeCommand } = loadExtension(mockMagento); + const themesProvider = createThemesProvider(['Magento/luma']); + + const item = new ThemeTreeItem({ code: 'Magento/luma' }); + await runMageforgeCommand( + { + id: 'mageforge.theme.build', + cliCommand: 'mageforge:theme:build', + acceptsThemes: true, + } as MageforgeCommand, + themesProvider, + item, + ); + + assert.strictEqual(terminals.length, 1); + assert.ok(terminals[0].command.includes('mageforge:theme:build')); + assert.ok(terminals[0].command.includes('Magento/luma')); + }); + + test('runs command without theme argument when not required', async () => { + const { runMageforgeCommand } = loadExtension(mockMagento); + const themesProvider = createThemesProvider([]); + + await runMageforgeCommand( + { + id: 'mageforge.theme.list', + cliCommand: 'mageforge:theme:list', + } as MageforgeCommand, + themesProvider, + ); + + assert.strictEqual(terminals.length, 1); + assert.ok(terminals[0].command.includes('mageforge:theme:list')); + assert.ok(!terminals[0].command.includes('Magento')); + }); + + test('uses watch terminal name for watch commands', async () => { + const { runMageforgeCommand } = loadExtension(mockMagento); + const themesProvider = createThemesProvider(['Magento/luma']); + + const item = new ThemeTreeItem({ code: 'Magento/luma' }); + await runMageforgeCommand( + { + id: 'mageforge.theme.watch', + cliCommand: 'mageforge:theme:watch', + label: 'Theme: Watch', + acceptsThemes: true, + isWatch: true, + } as MageforgeCommand, + themesProvider, + item, + ); + + assert.strictEqual(terminals[0].name, 'MageForge: Theme: Watch'); + }); + + test('does nothing when no Magento root is found', async () => { + mockMagento.getMagentoRoot = () => undefined; + const { runMageforgeCommand } = loadExtension(mockMagento); + const themesProvider = createThemesProvider([]); + + await runMageforgeCommand( + { + id: 'mageforge.theme.list', + cliCommand: 'mageforge:theme:list', + } as MageforgeCommand, + themesProvider, + ); + + assert.strictEqual(terminals.length, 0); + }); + }); + + suite('overrideFile', () => { + test('runs template override for selected file and theme', async () => { + const { overrideFile } = loadExtension(mockMagento); + const themesProvider = createThemesProvider(['Magento/luma']); + + const mockVscode = vscode as unknown as { + window: { + showQuickPick: (items: string[]) => Promise; + }; + }; + const originalShowQuickPick = mockVscode.window.showQuickPick; + mockVscode.window.showQuickPick = async () => 'Magento/luma'; + + try { + await overrideFile( + { fsPath: '/path/to/file.phtml', path: '/path/to/file.phtml' } as vscode.Uri, + themesProvider, + ); + + assert.strictEqual(terminals.length, 1); + assert.ok(terminals[0].command.includes('mageforge:template:override')); + assert.ok(terminals[0].command.includes('/path/to/file.phtml')); + assert.ok(terminals[0].command.includes('Magento/luma')); + } finally { + mockVscode.window.showQuickPick = originalShowQuickPick; + } + }); + + test('does nothing when user cancels theme selection', async () => { + const { overrideFile } = loadExtension(mockMagento); + const themesProvider = createThemesProvider(['Magento/luma']); + + const mockVscode = vscode as unknown as { + window: { + showQuickPick: (items: string[]) => Promise; + }; + }; + const originalShowQuickPick = mockVscode.window.showQuickPick; + mockVscode.window.showQuickPick = async () => undefined; + + try { + await overrideFile( + { fsPath: '/path/to/file.phtml', path: '/path/to/file.phtml' } as vscode.Uri, + themesProvider, + ); + assert.strictEqual(terminals.length, 0); + } finally { + mockVscode.window.showQuickPick = originalShowQuickPick; + } + }); + + test('does nothing when no file is selected', async () => { + const { overrideFile } = loadExtension(mockMagento); + const themesProvider = createThemesProvider([]); + + await overrideFile(undefined, themesProvider); + assert.strictEqual(terminals.length, 0); + }); + }); + + suite('showUpdateNotificationIfNeeded', () => { + test('shows changelog and notification when version changed', async () => { + const { showUpdateNotificationIfNeeded } = loadExtension(mockMagento); + const shownSubtitles: string[] = []; + const changelogProvider = { + show: (subtitle?: string) => { + shownSubtitles.push(subtitle ?? ''); + }, + } as import('../../changelogProvider').ChangelogViewProvider; + + const context = { + extension: { packageJSON: { version: '1.0.0' } }, + globalState: { + get: () => '0.9.0', + update: () => Promise.resolve(), + }, + } as unknown as vscode.ExtensionContext; + + const mockVscode = vscode as unknown as { + window: { + showInformationMessage: () => Promise; + }; + }; + const originalShowInfo = mockVscode.window.showInformationMessage; + mockVscode.window.showInformationMessage = async () => 'Dismiss'; + + try { + await showUpdateNotificationIfNeeded(context, changelogProvider); + assert.strictEqual(shownSubtitles.length, 1); + assert.strictEqual(shownSubtitles[0], 'v1.0.0'); + } finally { + mockVscode.window.showInformationMessage = originalShowInfo; + } + }); + + test('does nothing on first activation', async () => { + const { showUpdateNotificationIfNeeded } = loadExtension(mockMagento); + const shownSubtitles: string[] = []; + const changelogProvider = { + show: (subtitle?: string) => { + shownSubtitles.push(subtitle ?? ''); + }, + } as import('../../changelogProvider').ChangelogViewProvider; + + const context = { + extension: { packageJSON: { version: '1.0.0' } }, + globalState: { + get: () => undefined, + update: () => Promise.resolve(), + }, + } as unknown as vscode.ExtensionContext; + + await showUpdateNotificationIfNeeded(context, changelogProvider); + assert.strictEqual(shownSubtitles.length, 0); + }); + }); +}); diff --git a/src/test/unit/magento.test.ts b/src/test/unit/magento.test.ts new file mode 100644 index 0000000..1e198ee --- /dev/null +++ b/src/test/unit/magento.test.ts @@ -0,0 +1,233 @@ +import * as assert from 'assert'; +import * as fs from 'node:fs'; +import mockRequire = require('mock-require'); +import * as os from 'node:os'; +import * as path from 'node:path'; +import { restoreVscodeMock } from './setup'; + +type MockConfig = { + [key: string]: unknown; +}; + +function createMockVscode(config: MockConfig = {}): typeof import('vscode') { + return { + workspace: { + getConfiguration: (section: string) => ({ + get: (key: string, defaultValue?: T): T => { + const fullKey = section ? `${section}.${key}` : key; + const value = config[fullKey]; + return value !== undefined ? (value as T) : (defaultValue as T); + }, + update: () => Promise.resolve(), + }), + workspaceFolders: config['workspace.workspaceFolders'] as + { uri: { fsPath: string }; name: string; index: number }[] | undefined, + }, + } as unknown as typeof import('vscode'); +} + +suite('magento.ts unit tests', () => { + let sandbox: string; + let moduleUnderTest: typeof import('../../magento'); + + setup(() => { + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'mageforge-test-')); + }); + + teardown(() => { + fs.rmSync(sandbox, { recursive: true, force: true }); + // Restore the full vscode mock from setup.ts for other test suites. + restoreVscodeMock(); + }); + + function loadModule(config: MockConfig = {}): typeof import('../../magento') { + mockRequire('vscode', createMockVscode(config)); + return mockRequire.reRequire('../../magento'); + } + + function createMagentoRoot(): string { + fs.mkdirSync(path.join(sandbox, 'bin', 'magento'), { recursive: true }); + return sandbox; + } + + suite('getMagentoRoot', () => { + test('returns undefined when no workspace is open and no path is configured', () => { + moduleUnderTest = loadModule({ + 'mageforge.magentoRootPath': '', + 'workspace.workspaceFolders': undefined, + }); + assert.strictEqual(moduleUnderTest.getMagentoRoot(), undefined); + }); + + test('returns configured path when it points to a valid Magento root', () => { + const root = createMagentoRoot(); + moduleUnderTest = loadModule({ + 'mageforge.magentoRootPath': root, + 'workspace.workspaceFolders': undefined, + }); + assert.strictEqual(moduleUnderTest.getMagentoRoot(), root); + }); + + test('returns undefined when configured path is not a Magento root', () => { + moduleUnderTest = loadModule({ + 'mageforge.magentoRootPath': sandbox, + 'workspace.workspaceFolders': undefined, + }); + assert.strictEqual(moduleUnderTest.getMagentoRoot(), undefined); + }); + + test('falls back to first workspace folder', () => { + const root = createMagentoRoot(); + moduleUnderTest = loadModule({ + 'mageforge.magentoRootPath': '', + 'workspace.workspaceFolders': [{ uri: { fsPath: root }, name: 'test', index: 0 }], + }); + assert.strictEqual(moduleUnderTest.getMagentoRoot(), root); + }); + }); + + suite('getExecutionEnvironment', () => { + test('returns explicit setting when not auto', () => { + const root = createMagentoRoot(); + moduleUnderTest = loadModule({ 'mageforge.phpExecution': 'ddev' }); + assert.strictEqual(moduleUnderTest.getExecutionEnvironment(root), 'ddev'); + }); + + test('auto-detects ddev', () => { + const root = createMagentoRoot(); + fs.mkdirSync(path.join(root, '.ddev')); + moduleUnderTest = loadModule({ 'mageforge.phpExecution': 'auto' }); + assert.strictEqual(moduleUnderTest.getExecutionEnvironment(root), 'ddev'); + }); + + test('auto-detects docker-compose', () => { + const root = createMagentoRoot(); + fs.writeFileSync(path.join(root, 'docker-compose.yml'), ''); + moduleUnderTest = loadModule({ 'mageforge.phpExecution': 'auto' }); + assert.strictEqual(moduleUnderTest.getExecutionEnvironment(root), 'docker-compose'); + }); + + test('auto-detects lando', () => { + const root = createMagentoRoot(); + fs.writeFileSync(path.join(root, '.lando.yml'), ''); + moduleUnderTest = loadModule({ 'mageforge.phpExecution': 'auto' }); + assert.strictEqual(moduleUnderTest.getExecutionEnvironment(root), 'lando'); + }); + + test('defaults to local', () => { + const root = createMagentoRoot(); + moduleUnderTest = loadModule({ 'mageforge.phpExecution': 'auto' }); + assert.strictEqual(moduleUnderTest.getExecutionEnvironment(root), 'local'); + }); + }); + + suite('buildCommandLine', () => { + test('builds simple local command', () => { + const root = createMagentoRoot(); + moduleUnderTest = loadModule({ + 'mageforge.phpExecution': 'local', + 'mageforge.phpBinary': 'php', + }); + const line = moduleUnderTest.buildCommandLine(root, 'mageforge:theme:build', [ + 'Magento/luma', + ]); + assert.strictEqual(line, 'php bin/magento mageforge\\:theme\\:build Magento/luma'); + }); + + test('quotes paths with spaces', () => { + const root = createMagentoRoot(); + moduleUnderTest = loadModule({ + 'mageforge.phpExecution': 'local', + 'mageforge.phpBinary': 'php', + }); + const line = moduleUnderTest.buildCommandLine(root, 'mageforge:template:override', [ + '/path/with spaces/file.phtml', + '--theme', + 'Magento/luma', + ]); + assert.ok(line.includes("'/path/with spaces/file.phtml'")); + assert.ok(line.includes('Magento/luma')); + }); + + test('prevents shell injection via theme code', () => { + const root = createMagentoRoot(); + moduleUnderTest = loadModule({ + 'mageforge.phpExecution': 'local', + 'mageforge.phpBinary': 'php', + }); + const line = moduleUnderTest.buildCommandLine(root, 'mageforge:theme:build', [ + 'Vendor/theme; rm -rf /', + ]); + // The dangerous payload must be wrapped in single quotes so the shell + // treats it as a single argument instead of executing it. + assert.ok(line.includes("'Vendor/theme; rm -rf /'")); + assert.ok(!line.includes('Vendor/theme; rm -rf / ')); + }); + + test('handles phpBinary with spaces', () => { + const root = createMagentoRoot(); + moduleUnderTest = loadModule({ + 'mageforge.phpExecution': 'local', + 'mageforge.phpBinary': 'docker-compose exec php', + }); + const line = moduleUnderTest.buildCommandLine(root, 'mageforge:theme:build', [ + 'Magento/luma', + ]); + assert.strictEqual( + line, + 'docker-compose exec php bin/magento mageforge\\:theme\\:build Magento/luma', + ); + }); + + test('builds ddev command', () => { + const root = createMagentoRoot(); + moduleUnderTest = loadModule({ 'mageforge.phpExecution': 'ddev' }); + const line = moduleUnderTest.buildCommandLine(root, 'mageforge:theme:list'); + assert.strictEqual(line, 'ddev php bin/magento mageforge\\:theme\\:list'); + }); + + test('builds docker-compose command with custom service', () => { + const root = createMagentoRoot(); + moduleUnderTest = loadModule({ + 'mageforge.phpExecution': 'docker-compose', + 'mageforge.dockerComposeService': 'app', + }); + const line = moduleUnderTest.buildCommandLine(root, 'mageforge:theme:list'); + assert.strictEqual( + line, + 'docker-compose exec app bin/magento mageforge\\:theme\\:list', + ); + }); + + test('quotes Windows-style paths safely', () => { + const root = createMagentoRoot(); + moduleUnderTest = loadModule({ + 'mageforge.phpExecution': 'local', + 'mageforge.phpBinary': 'php', + }); + const line = moduleUnderTest.buildCommandLine(root, 'mageforge:template:override', [ + 'C:\\Users\\test\\file.phtml', + '--theme', + 'Magento/luma', + ]); + assert.ok(line.includes('C:\\Users\\test\\file.phtml')); + assert.ok(line.includes('Magento/luma')); + }); + }); + + suite('buildComposerUpdateCommand', () => { + test('builds local composer update command', () => { + const root = createMagentoRoot(); + moduleUnderTest = loadModule({ 'mageforge.phpExecution': 'local' }); + const line = moduleUnderTest.buildComposerUpdateCommand(root); + assert.strictEqual(line, 'composer update openforgeproject/mageforge'); + }); + + test('builds ddev composer update command', () => { + const root = createMagentoRoot(); + moduleUnderTest = loadModule({ 'mageforge.phpExecution': 'ddev' }); + const line = moduleUnderTest.buildComposerUpdateCommand(root, 'vendor/package'); + assert.strictEqual(line, 'ddev composer update vendor/package'); + }); + }); +}); diff --git a/src/test/unit/setup.ts b/src/test/unit/setup.ts new file mode 100644 index 0000000..19d4944 --- /dev/null +++ b/src/test/unit/setup.ts @@ -0,0 +1,198 @@ +import mockRequire = require('mock-require'); + +interface MockConfig { + [key: string]: unknown; +} + +let lastWebviewPanel: { webview: ReturnType } | undefined; +const executedCommands: string[] = []; +const openedExternals: string[] = []; + +export function getLastWebviewPanel(): + { webview: ReturnType } | undefined { + return lastWebviewPanel; +} + +const defaultVscodeMock = createMockVscode(); + +export function restoreVscodeMock(): void { + mockRequire('vscode', defaultVscodeMock); +} + +export function resetMockState(): void { + executedCommands.length = 0; + openedExternals.length = 0; +} + +export const mochaHooks = { + beforeEach(): void { + resetMockState(); + }, +}; + +export type CapturedMessage = { + url?: string; + command?: string; + type?: string; + mageforge?: string; + latest?: string; + outdated?: boolean; + isDev?: boolean; +}; + +export function createMockWebview(): { + asWebviewUri: (uri: { fsPath: string }) => { toString: () => string }; + html: string; + cspSource: string; + messages: CapturedMessage[]; + postMessage: (message: CapturedMessage) => boolean; + onDidReceiveMessageHandler: ((message: CapturedMessage) => void) | undefined; + onDidReceiveMessage: (handler: (message: CapturedMessage) => void) => void; +} { + const messages: CapturedMessage[] = []; + return { + asWebviewUri: (uri: { fsPath: string }) => ({ + toString: () => `vscode-webview://mageforge/${uri.fsPath}`, + }), + html: '', + cspSource: 'vscode-webview://mageforge', + messages, + postMessage: (message: CapturedMessage) => { + messages.push(message); + return true; + }, + onDidReceiveMessageHandler: undefined, + onDidReceiveMessage: function (handler: (message: CapturedMessage) => void) { + this.onDidReceiveMessageHandler = handler; + }, + }; +} + +export function createMockWebviewView(): { + visible: boolean; + webview: ReturnType; + onDidChangeVisibilityHandler: (() => void) | undefined; + onDidChangeVisibility: (handler: () => void) => void; +} { + return { + visible: true, + webview: createMockWebview(), + onDidChangeVisibilityHandler: undefined, + onDidChangeVisibility: function (handler: () => void) { + this.onDidChangeVisibilityHandler = handler; + }, + }; +} + +function createMockVscode(config: MockConfig = {}): typeof import('vscode') { + return { + workspace: { + getConfiguration: (section: string) => ({ + get: (key: string, defaultValue?: T): T => { + const fullKey = section ? `${section}.${key}` : key; + const value = config[fullKey]; + return value !== undefined ? (value as T) : (defaultValue as T); + }, + update: () => Promise.resolve(), + }), + workspaceFolders: config['workspace.workspaceFolders'] as + { uri: { fsPath: string }; name: string; index: number }[] | undefined, + }, + window: { + createTerminal: () => ({ + show: () => undefined, + sendText: () => undefined, + }), + createWebviewPanel: () => { + const webview = createMockWebview(); + lastWebviewPanel = { webview }; + return { + webview, + title: '', + reveal: () => undefined, + onDidDispose: () => undefined, + dispose: () => undefined, + }; + }, + terminals: [], + showErrorMessage: () => Promise.resolve(undefined), + showInformationMessage: () => Promise.resolve(undefined), + showQuickPick: () => Promise.resolve(undefined), + activeTextEditor: config['window.activeTextEditor'] as + { document: { uri: { fsPath: string } } } | undefined, + }, + commands: { + executeCommand: (command: string, ..._args: unknown[]) => { + executedCommands.push(command); + return Promise.resolve(); + }, + get executedCommands() { + return executedCommands; + }, + }, + env: { + openExternal: (uri: { toString: () => string }) => { + openedExternals.push(uri.toString()); + return Promise.resolve(true); + }, + get openedExternals() { + return openedExternals; + }, + }, + ViewColumn: { + One: 1, + Two: 2, + Three: 3, + }, + ThemeIcon: class ThemeIcon { + constructor(public readonly id: string) {} + }, + ThemeColor: class ThemeColor { + constructor(public readonly id: string) {} + }, + TreeItem: class TreeItem { + constructor( + public readonly label: string, + public readonly collapsibleState: number, + ) {} + }, + TreeItemCollapsibleState: { + None: 0, + Collapsed: 1, + Expanded: 2, + }, + EventEmitter: class EventEmitter { + private listeners: Array<(event: T) => void> = []; + event = (listener: (event: T) => void) => { + this.listeners.push(listener); + return { dispose: () => undefined }; + }; + fire = (event: T) => { + this.listeners.forEach((listener) => listener(event)); + }; + }, + MarkdownString: class MarkdownString { + public value: string; + constructor(value: string) { + this.value = value; + } + appendText(text: string): typeof this { + this.value += text; + return this; + } + appendMarkdown(text: string): typeof this { + this.value += text; + return this; + } + }, + Uri: { + file: (path: string) => ({ fsPath: path, toString: () => path }), + parse: (path: string) => ({ fsPath: path, toString: () => path }), + joinPath: (...parts: (string | { fsPath: string })[]) => ({ + fsPath: parts.map((p) => (typeof p === 'string' ? p : p.fsPath)).join('/'), + }), + }, + } as unknown as typeof import('vscode'); +} + +mockRequire('vscode', createMockVscode()); diff --git a/src/test/unit/themesProvider.test.ts b/src/test/unit/themesProvider.test.ts new file mode 100644 index 0000000..0514ce9 --- /dev/null +++ b/src/test/unit/themesProvider.test.ts @@ -0,0 +1,90 @@ +import * as assert from 'assert'; +import { parseThemeList } from '../../themesProvider'; + +suite('themesProvider.ts unit tests', () => { + suite('parseThemeList', () => { + test('parses standard mageforge:theme:list output', () => { + const output = ` + ┌─────────────────┬────────┬──────────────────────────────────────────┐ + │ Code │ Title │ Path │ + ├─────────────────┼────────┼──────────────────────────────────────────┤ + │ Magento/blank │ Blank │ vendor/magento/theme-frontend-blank │ + │ Magento/luma │ Luma │ vendor/magento/theme-frontend-luma │ + │ Magento/backend │ Admin │ adminhtml/Magento/backend │ + └─────────────────┴────────┴──────────────────────────────────────────┘ + `; + + const themes = parseThemeList(output); + + assert.strictEqual(themes.length, 3); + assert.deepStrictEqual(themes[0], { + code: 'Magento/blank', + title: 'Blank', + area: 'frontend', + }); + assert.deepStrictEqual(themes[1], { + code: 'Magento/luma', + title: 'Luma', + area: 'frontend', + }); + assert.deepStrictEqual(themes[2], { + code: 'Magento/backend', + title: 'Admin', + area: 'adminhtml', + }); + }); + + test('handles ANSI colored output', () => { + const output = + '\x1B[32m│ Magento/luma │\x1B[0m \x1B[33mLuma\x1B[0m │ vendor/magento/theme-frontend-luma │'; + + const themes = parseThemeList(output); + + assert.strictEqual(themes.length, 1); + assert.deepStrictEqual(themes[0], { + code: 'Magento/luma', + title: 'Luma', + area: 'frontend', + }); + }); + + test('skips rows without a valid theme code', () => { + const output = ` + │ Code │ Title │ Path │ + │ Magento/luma │ Luma │ vendor/magento/theme-frontend-luma │ + `; + + const themes = parseThemeList(output); + + assert.strictEqual(themes.length, 1); + assert.strictEqual(themes[0].code, 'Magento/luma'); + }); + + test('handles missing title', () => { + const output = '│ Custom/theme │ │ vendor/magento/theme-frontend-blank │'; + + const themes = parseThemeList(output); + + assert.strictEqual(themes.length, 1); + assert.strictEqual(themes[0].code, 'Custom/theme'); + assert.strictEqual(themes[0].title, undefined); + }); + + test('deduplicates theme codes keeping first occurrence', () => { + const output = ` + │ Magento/luma │ Luma │ vendor/magento/theme-frontend-luma │ + │ Magento/luma │ Duplicate │ vendor/magento/theme-frontend-luma │ + `; + + const themes = parseThemeList(output); + + assert.strictEqual(themes.length, 1); + assert.strictEqual(themes[0].title, 'Luma'); + }); + + test('handles empty output', () => { + const themes = parseThemeList(''); + assert.deepStrictEqual(themes, []); + }); + }); +}); diff --git a/src/test/unit/themesProviderClass.test.ts b/src/test/unit/themesProviderClass.test.ts new file mode 100644 index 0000000..b72592e --- /dev/null +++ b/src/test/unit/themesProviderClass.test.ts @@ -0,0 +1,140 @@ +import * as assert from 'assert'; +import mockRequire = require('mock-require'); +import { createMockWebview, createMockWebviewView } from './setup'; + +type MockMagento = { + getMagentoRoot: () => string | undefined; + execMageforge: (root: string, command: string) => Promise; +}; + +function loadThemesProvider(magentoMock: MockMagento) { + mockRequire('../../magento', magentoMock); + return mockRequire.reRequire('../../themesProvider') as typeof import('../../themesProvider'); +} + +suite('ThemesProvider class unit tests', () => { + teardown(() => { + mockRequire.stop('../../magento'); + }); + + test('loads themes on first getChildren call', async () => { + const { ThemesProvider } = loadThemesProvider({ + getMagentoRoot: () => '/magento', + execMageforge: async () => + '│ Magento/luma │ Luma │ vendor/magento/theme-frontend-luma │', + }); + + const provider = new ThemesProvider(); + const children = await provider.getChildren(); + + assert.strictEqual(children.length, 1); + assert.strictEqual(children[0].theme?.code, 'Magento/luma'); + }); + + test('returns cached themes without calling execMageforge again', async () => { + let calls = 0; + const { ThemesProvider } = loadThemesProvider({ + getMagentoRoot: () => '/magento', + execMageforge: async () => { + calls++; + return '│ Magento/luma │ Luma │ vendor/magento/theme-frontend-luma │'; + }, + }); + + const provider = new ThemesProvider(); + await provider.getChildren(); + await provider.getThemeCodes(); + await provider.getChildren(); + + assert.strictEqual(calls, 1); + assert.deepStrictEqual(await provider.getThemeCodes(), ['Magento/luma']); + }); + + test('handles loading error and allows retry after refresh', async () => { + let shouldFail = true; + const { ThemesProvider } = loadThemesProvider({ + getMagentoRoot: () => '/magento', + execMageforge: async () => { + if (shouldFail) { + throw new Error('Command failed'); + } + return '│ Magento/luma │ Luma │ vendor/magento/theme-frontend-luma │'; + }, + }); + + const provider = new ThemesProvider(); + const children = await provider.getChildren(); + + assert.strictEqual(children.length, 1); + assert.strictEqual(children[0].theme, undefined); + assert.ok(children[0].label?.toString().includes('Could not load')); + + shouldFail = false; + provider.refresh(); + const retried = await provider.getChildren(); + + assert.strictEqual(retried.length, 1); + assert.strictEqual(retried[0].theme?.code, 'Magento/luma'); + }); + + test('shows error when no Magento root is found', async () => { + const { ThemesProvider } = loadThemesProvider({ + getMagentoRoot: () => undefined, + execMageforge: async () => '', + }); + + const provider = new ThemesProvider(); + const children = await provider.getChildren(); + + assert.strictEqual(children.length, 1); + assert.strictEqual(children[0].theme, undefined); + assert.ok(children[0].label?.toString().includes('Could not load')); + }); + + test('returns empty array for child elements', async () => { + const { ThemesProvider, ThemeTreeItem } = loadThemesProvider({ + getMagentoRoot: () => '/magento', + execMageforge: async () => + '│ Magento/luma │ Luma │ vendor/magento/theme-frontend-luma │', + }); + + const provider = new ThemesProvider(); + const theme = new ThemeTreeItem({ code: 'Magento/luma' }); + const children = await provider.getChildren(theme); + + assert.deepStrictEqual(children, []); + }); + + test('tree item uses correct icon for adminhtml themes', async () => { + const { ThemesProvider } = loadThemesProvider({ + getMagentoRoot: () => '/magento', + execMageforge: async () => '│ Magento/backend │ Admin │ adminhtml/Magento/backend │', + }); + + const provider = new ThemesProvider(); + const children = await provider.getChildren(); + + assert.strictEqual(children[0].theme?.area, 'adminhtml'); + assert.strictEqual((children[0].iconPath as { id: string }).id, 'shield'); + }); + + test('race condition: parallel calls share a single load', async () => { + let calls = 0; + const { ThemesProvider } = loadThemesProvider({ + getMagentoRoot: () => '/magento', + execMageforge: async () => { + calls++; + // Simulate slow load + await new Promise((resolve) => setTimeout(resolve, 10)); + return '│ Magento/luma │ Luma │ vendor/magento/theme-frontend-luma │'; + }, + }); + + const provider = new ThemesProvider(); + const [a, b] = await Promise.all([provider.getChildren(), provider.getThemeCodes()]); + + assert.strictEqual(calls, 1); + assert.strictEqual(a.length, 1); + assert.deepStrictEqual(b, ['Magento/luma']); + }); +}); diff --git a/src/test/unit/url.test.ts b/src/test/unit/url.test.ts new file mode 100644 index 0000000..6c76b38 --- /dev/null +++ b/src/test/unit/url.test.ts @@ -0,0 +1,33 @@ +import * as assert from 'node:assert'; +import { isAllowedExternalUrl } from '../../url'; + +function makeTestUrl(protocol: 'https' | 'http', host: string, path?: string): string { + return `${protocol}://${host}${path ? `/${path}` : ''}`; +} + +suite('url.ts unit tests', () => { + test('isAllowedExternalUrl accepts https URLs', () => { + assert.strictEqual(isAllowedExternalUrl(makeTestUrl('https', 'example.com')), true); + assert.strictEqual( + isAllowedExternalUrl(makeTestUrl('https', 'github.com', 'OpenForgeProject/mageforge')), + true, + ); + }); + + test('isAllowedExternalUrl accepts http URLs', () => { + assert.strictEqual(isAllowedExternalUrl(makeTestUrl('http', 'example.com')), true); + }); + + test('isAllowedExternalUrl rejects non-http protocols', () => { + assert.strictEqual(isAllowedExternalUrl('file:///etc/passwd'), false); + assert.strictEqual(isAllowedExternalUrl('javascript:alert(1)'), false); + assert.strictEqual(isAllowedExternalUrl('vscode://file/etc/passwd'), false); + assert.strictEqual(isAllowedExternalUrl('data:text/html,'), false); + }); + + test('isAllowedExternalUrl rejects malformed URLs', () => { + assert.strictEqual(isAllowedExternalUrl(''), false); + assert.strictEqual(isAllowedExternalUrl('not a url'), false); + assert.strictEqual(isAllowedExternalUrl('/local/path'), false); + }); +}); diff --git a/src/test/unit/welcomeProvider.test.ts b/src/test/unit/welcomeProvider.test.ts new file mode 100644 index 0000000..1ad8347 --- /dev/null +++ b/src/test/unit/welcomeProvider.test.ts @@ -0,0 +1,158 @@ +import * as assert from 'assert'; +import mockRequire = require('mock-require'); +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { createMockWebviewView } from './setup'; + +function makeTestUrl(path: string): string { + return `https://github.com/${path}`; +} + +type MockMagento = { + getMagentoRoot: () => string | undefined; + getExecutionEnvironment: (root: string) => string; +}; + +function loadWelcomeProvider(magentoMock: MockMagento) { + mockRequire('../../magento', magentoMock); + return mockRequire.reRequire('../../welcomeProvider') as typeof import('../../welcomeProvider'); +} + +suite('welcomeProvider.ts unit tests', () => { + let sandbox: string; + + setup(() => { + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'mageforge-welcome-test-')); + }); + + teardown(() => { + mockRequire.stop('../../magento'); + fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + function createMagentoRoot(): string { + fs.mkdirSync(path.join(sandbox, 'bin', 'magento'), { recursive: true }); + return sandbox; + } + + test('renders welcome HTML with environment badge', async () => { + const { WelcomeViewProvider } = loadWelcomeProvider({ + getMagentoRoot: () => createMagentoRoot(), + getExecutionEnvironment: () => 'ddev', + }); + + const provider = new WelcomeViewProvider({ fsPath: '/ext' } as import('vscode').Uri); + const view = createMockWebviewView(); + await provider.resolveWebviewView(view as unknown as import('vscode').WebviewView); + + assert.ok(view.webview.html.includes('DDEV')); + assert.ok(view.webview.html.includes('Build Theme')); + assert.ok(view.webview.html.includes('mageforge.theme.build')); + }); + + test('handles webviewReady message by sending version info', async () => { + const root = createMagentoRoot(); + fs.mkdirSync(path.join(root, 'vendor', 'composer'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'vendor', 'composer', 'installed.json'), + JSON.stringify({ + packages: [{ name: 'openforgeproject/mageforge', version: '1.2.3' }], + }), + ); + + const { WelcomeViewProvider } = loadWelcomeProvider({ + getMagentoRoot: () => root, + getExecutionEnvironment: () => 'local', + }); + + const provider = new WelcomeViewProvider({ fsPath: '/ext' } as import('vscode').Uri); + const view = createMockWebviewView(); + await provider.resolveWebviewView(view as unknown as import('vscode').WebviewView); + + assert.ok(view.webview.onDidReceiveMessageHandler); + view.webview.onDidReceiveMessageHandler!({ type: 'webviewReady' }); + + // Wait for async version check with a small poll. + let versionMessage = view.webview.messages.find((m) => m.type === 'versionInfo'); + for (let i = 0; i < 50 && !versionMessage; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + versionMessage = view.webview.messages.find((m) => m.type === 'versionInfo'); + } + + assert.ok(versionMessage, `messages: ${JSON.stringify(view.webview.messages)}`); + assert.strictEqual(versionMessage.mageforge, '1.2.3'); + }); + + test('webview message with url opens external link', async () => { + const vscode = require('vscode'); + const { WelcomeViewProvider } = loadWelcomeProvider({ + getMagentoRoot: () => createMagentoRoot(), + getExecutionEnvironment: () => 'local', + }); + + const provider = new WelcomeViewProvider({ fsPath: '/ext' } as import('vscode').Uri); + const view = createMockWebviewView(); + await provider.resolveWebviewView(view as unknown as import('vscode').WebviewView); + const testUrl = makeTestUrl('OpenForgeProject/mageforge'); + + view.webview.onDidReceiveMessageHandler!({ + url: testUrl, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.ok(vscode.env.openedExternals.includes(testUrl)); + }); + + test('webview message with dangerous url is ignored', async () => { + const vscode = require('vscode'); + const { WelcomeViewProvider } = loadWelcomeProvider({ + getMagentoRoot: () => createMagentoRoot(), + getExecutionEnvironment: () => 'local', + }); + + const provider = new WelcomeViewProvider({ fsPath: '/ext' } as import('vscode').Uri); + const view = createMockWebviewView(); + await provider.resolveWebviewView(view as unknown as import('vscode').WebviewView); + + view.webview.onDidReceiveMessageHandler!({ + url: 'javascript:alert(1)', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.strictEqual(vscode.env.openedExternals.length, 0); + }); + + test('webview message with command executes command', async () => { + const vscode = require('vscode'); + const { WelcomeViewProvider } = loadWelcomeProvider({ + getMagentoRoot: () => createMagentoRoot(), + getExecutionEnvironment: () => 'local', + }); + + const provider = new WelcomeViewProvider({ fsPath: '/ext' } as import('vscode').Uri); + const view = createMockWebviewView(); + await provider.resolveWebviewView(view as unknown as import('vscode').WebviewView); + + view.webview.onDidReceiveMessageHandler!({ + command: 'mageforge.theme.build', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.ok(vscode.commands.executedCommands.includes('mageforge.theme.build')); + }); + + test('isOutdated detects older versions', async () => { + const { WelcomeViewProvider } = loadWelcomeProvider({ + getMagentoRoot: () => undefined, + getExecutionEnvironment: () => 'local', + }); + + const provider = new WelcomeViewProvider({ fsPath: '/ext' } as import('vscode').Uri); + assert.strictEqual((provider as any).isOutdated('1.2.3', '1.2.4'), true); + assert.strictEqual((provider as any).isOutdated('1.2.3', '1.3.0'), true); + assert.strictEqual((provider as any).isOutdated('1.2.3', '2.0.0'), true); + assert.strictEqual((provider as any).isOutdated('1.2.3', '1.2.3'), false); + assert.strictEqual((provider as any).isOutdated('1.2.3', '1.2.2'), false); + }); +}); diff --git a/src/themesProvider.ts b/src/themesProvider.ts index 4d022fc..125b899 100644 --- a/src/themesProvider.ts +++ b/src/themesProvider.ts @@ -118,7 +118,9 @@ export function parseThemeList(output: string): MagentoTheme[] { const pathCell = cells.find((cell) => /^(frontend|adminhtml)\//.test(cell)); const area = pathCell?.startsWith('adminhtml/') ? 'adminhtml' : 'frontend'; - themes.set(code, { code, title, area }); + if (!themes.has(code)) { + themes.set(code, { code, title, area }); + } } return [...themes.values()]; diff --git a/src/url.ts b/src/url.ts new file mode 100644 index 0000000..e1b3250 --- /dev/null +++ b/src/url.ts @@ -0,0 +1,14 @@ +const ALLOWED_PROTOCOLS = ['http:', 'https:']; + +/** + * Validate that a value is a safe URL for opening in an external browser. + * Only absolute http/https URLs are accepted; everything else is rejected. + */ +export function isAllowedExternalUrl(value: string): boolean { + try { + const url = new URL(value); + return ALLOWED_PROTOCOLS.includes(url.protocol); + } catch { + return false; + } +} diff --git a/src/welcomeProvider.ts b/src/welcomeProvider.ts index 742a27b..1f16a00 100644 --- a/src/welcomeProvider.ts +++ b/src/welcomeProvider.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { getMagentoRoot, getExecutionEnvironment } from './magento'; +import { isAllowedExternalUrl } from './url'; import { execFile } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -51,7 +52,7 @@ export class WelcomeViewProvider implements vscode.WebviewViewProvider { webviewView.webview.onDidReceiveMessage( (message: { command?: string; url?: string; type?: string }) => { - if (message.url) { + if (message.url && isAllowedExternalUrl(message.url)) { void vscode.env.openExternal(vscode.Uri.parse(message.url)); } else if (message.command) { void vscode.commands.executeCommand(message.command); diff --git a/tsconfig.json b/tsconfig.json index 224fc26..b6a5f4c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,12 @@ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ // "noUnusedParameters": true, /* Report errors on unused parameters. */ + }, + "ts-node": { + "esm": false, + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "node" + } } }