diff --git a/config/test-dependencies.json b/config/test-dependencies.json index 343b1c22fa..6a2ff78a7d 100644 --- a/config/test-dependencies.json +++ b/config/test-dependencies.json @@ -1,15 +1,40 @@ [ { - "name": "@jsdevtools/coverage-istanbul-loader" + "name": "vitest", + "framework": "vitest", + "version": "~4.1.10" }, { - "name": "karma" + "name": "@vitest/runner", + "framework": "vitest", + "version": "~4.1.10" }, { - "name": "karma-coverage" + "name": "@nativescript/unit-test-runner", + "framework": "vitest", + "version": "^5.0.0-alpha.0" }, { - "name": "karma-nativescript-launcher" + "name": "@valor/nativescript-websockets", + "framework": "vitest", + "version": "^2.0.3", + "saveInDependencies": true + }, + { + "name": "@jsdevtools/coverage-istanbul-loader", + "frameworks": ["jasmine", "mocha", "qunit"] + }, + { + "name": "karma", + "frameworks": ["jasmine", "mocha", "qunit"] + }, + { + "name": "karma-coverage", + "frameworks": ["jasmine", "mocha", "qunit"] + }, + { + "name": "karma-nativescript-launcher", + "frameworks": ["jasmine", "mocha", "qunit"] }, { "name": "mocha", @@ -53,6 +78,7 @@ "projectType": ".ts" }, { - "name": "nyc" + "name": "nyc", + "frameworks": ["jasmine", "mocha", "qunit"] } -] \ No newline at end of file +] diff --git a/docs/man_pages/project/testing/test-android.md b/docs/man_pages/project/testing/test-android.md index efa789469a..46d3f460a5 100644 --- a/docs/man_pages/project/testing/test-android.md +++ b/docs/man_pages/project/testing/test-android.md @@ -43,4 +43,5 @@ Command | Description --------|------------ [test init](test-init.html) | Configures your project for unit testing with a selected framework. [test ios](test-ios.html) | Runs the tests in your project on iOS devices or the iOS Simulator. +[test visionos](test-visionos.html) | Runs the tests in your project in the visionOS Simulator or on Apple Vision Pro devices. <% } %> diff --git a/docs/man_pages/project/testing/test-init.md b/docs/man_pages/project/testing/test-init.md index c19827d161..638295d649 100644 --- a/docs/man_pages/project/testing/test-init.md +++ b/docs/man_pages/project/testing/test-init.md @@ -9,6 +9,8 @@ position: 21 Configures your project for unit testing with a selected framework. This operation installs the @nativescript/unit-test-runner npm module and its dependencies and creates a `tests` folder in the `app` directory. +The recommended framework is `vitest`, which runs your specs inside real NativeScript runtimes on device/emulator and also supports UI testing. The Karma-based frameworks (jasmine, mocha, qunit) are deprecated and will be removed in a future release. + ### Commands Usage | Synopsis @@ -17,7 +19,7 @@ General | `$ ns test init [--framework ]` ### Options -* `--framework ` - Sets the unit testing framework to install. The following frameworks are available: mocha, jasmine and qunit. +* `--framework ` - Sets the unit testing framework to install. The following frameworks are available: vitest (recommended), mocha, jasmine and qunit (deprecated). <% if(isHtml) { %> @@ -31,4 +33,5 @@ Command | Description --------|------------ [test android](test-android.html) | Runs the tests in your project on Android devices or native emulators. [test ios](test-ios.html) | Runs the tests in your project on iOS devices or the iOS Simulator. +[test visionos](test-visionos.html) | Runs the tests in your project in the visionOS Simulator or on Apple Vision Pro devices. <% } %> \ No newline at end of file diff --git a/docs/man_pages/project/testing/test-ios.md b/docs/man_pages/project/testing/test-ios.md index 8a421559a8..dac2ccfccb 100644 --- a/docs/man_pages/project/testing/test-ios.md +++ b/docs/man_pages/project/testing/test-ios.md @@ -45,4 +45,5 @@ Command | Description --------|------------ [test init](test-init.html) | Configures your project for unit testing with a selected framework. [test android](test-android.html) | Runs the tests in your project on Android devices or native emulators. +[test visionos](test-visionos.html) | Runs the tests in your project in the visionOS Simulator or on Apple Vision Pro devices. <% } %> diff --git a/docs/man_pages/project/testing/test-visionos.md b/docs/man_pages/project/testing/test-visionos.md new file mode 100644 index 0000000000..af48d8175c --- /dev/null +++ b/docs/man_pages/project/testing/test-visionos.md @@ -0,0 +1,46 @@ +<% if (isJekyll) { %>--- +title: ns test visionos +position: 24 +---<% } %> + +# ns test visionos + +### Description + +Runs the tests in your project in the visionOS Simulator or on connected Apple Vision Pro devices.<% if(isConsole && isMacOS) { %> Your project must already be configured for unit testing with the Vitest framework by running `$ ns test init --framework vitest`.<% } %> Unit testing on visionOS requires the Vitest testing framework; the deprecated Karma-based frameworks are not supported on this platform. + +<% if(isConsole && (isLinux || isWindows)) { %>WARNING: You can run this command only on macOS systems. To view the complete help for this command, run `$ ns help test visionos`<% } %> + +### Commands + +Usage | Synopsis +------|------- +Run tests in the visionOS Simulator | `$ ns test visionos` +Run tests on a selected device | `$ ns test visionos --device ` + +<% if((isConsole && isMacOS) || isHtml) { %> + +### Options + +* `--device` - Specifies the serial number or the index of the connected device on which you want to run tests. To list all connected devices, grouped by platform, run `$ ns device`. `` is the device index or identifier as listed by the `$ ns device` command. +* `--env.codeCoverage` - If set, collects code coverage for the test run. +* `--force` - If set, skips the application compatibility checks and forces `npm i` to ensure all dependencies are installed. Otherwise, the command will check the application compatibility with the current CLI version and could fail requiring `ns migrate`. + +<% } %> + +<% if(isHtml) { %> + +### Prerequisites + +* Verify that [you have configured your project for unit testing](test-init.html) with the Vitest framework. +* Verify that [you have stored your unit tests in `app` → `tests`](http://docs.nativescript.org/testing). +* Verify that [you have configured your system and devices properly](http://docs.nativescript.org/testing). + +### Related Commands + +Command | Description +--------|------------ +[test init](test-init.html) | Configures your project for unit testing with a selected framework. +[test android](test-android.html) | Runs the tests in your project on Android devices or native emulators. +[test ios](test-ios.html) | Runs the tests in your project on iOS devices or the iOS Simulator. +<% } %> diff --git a/docs/man_pages/project/testing/test.md b/docs/man_pages/project/testing/test.md index 5da36cbe25..3a77080cae 100644 --- a/docs/man_pages/project/testing/test.md +++ b/docs/man_pages/project/testing/test.md @@ -28,7 +28,8 @@ Usage | Synopsis <% if((isConsole && isMacOS) || isHtml) { %>### Arguments `` is the target mobile platform on which you want to run the tests. You can set the following target platforms. * `android` - Runs the tests in your project on connected Android devices or Android emulators. -* `ios` - Runs the tests in your project on connected iOS devices.<% } %> +* `ios` - Runs the tests in your project on connected iOS devices. +* `visionos` - Runs the tests in your project in the visionOS Simulator or on connected Apple Vision Pro devices. Requires the Vitest testing framework.<% } %> <% if(isHtml) { %> @@ -45,4 +46,5 @@ Command | Description [test init](test-init.html) | Configures your project for unit testing with a selected framework. [test android](test-android.html) | Runs the tests in your project on Android devices or native emulators. [test ios](test-ios.html) | Runs the tests in your project on iOS devices or the iOS Simulator. +[test visionos](test-visionos.html) | Runs the tests in your project in the visionOS Simulator or on Apple Vision Pro devices. <% } %> diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 7c88a6d811..a2a8b9d285 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -203,12 +203,16 @@ injector.requireCommand("deploy", "./commands/deploy"); injector.requireCommand("embed", "./commands/embedding/embed"); injector.require("testExecutionService", "./services/test-execution-service"); +injector.require( + "vitestExecutionService", + "./services/vitest-execution-service", +); injector.requireCommand("dev-test|android", "./commands/test"); injector.requireCommand("dev-test|ios", "./commands/test"); injector.requireCommand("test|android", "./commands/test"); injector.requireCommand("test|ios", "./commands/test"); -// injector.requireCommand("test|vision", "./commands/test"); -// injector.requireCommand("test|visionos", "./commands/test"); +injector.requireCommand("test|vision", "./commands/test"); +injector.requireCommand("test|visionos", "./commands/test"); injector.requireCommand("test|init", "./commands/test-init"); injector.requireCommand("dev-generate-help", "./commands/generate-help"); diff --git a/lib/commands/test-init.ts b/lib/commands/test-init.ts index 32b55ad04c..8cf06ebd4e 100644 --- a/lib/commands/test-init.ts +++ b/lib/commands/test-init.ts @@ -26,6 +26,70 @@ class TestInitCommand implements ICommand { mocha: ["chai"], }; + /** + * Android blocks cleartext traffic by default (API 28+), which would + * reject the runner's ws:// connection to the host. Scope the exception + * to the emulator loopback alias and adb-reverse loopback only. + */ + private ensureAndroidNetworkSecurityConfig(bufferedLogs: string[]): void { + const manifestPath = path.join( + this.$projectData.appResourcesDirectoryPath, + "Android", + "src", + "main", + "AndroidManifest.xml", + ); + if (!this.$fs.exists(manifestPath)) { + bufferedLogs.push( + color.yellow( + "Could not locate App_Resources/Android/src/main/AndroidManifest.xml. For Android test runs, allow cleartext traffic to 10.0.2.2 and 127.0.0.1 via a network security config.", + ), + ); + return; + } + + const manifestContent = this.$fs.readText(manifestPath); + if (manifestContent.indexOf("networkSecurityConfig") !== -1) { + bufferedLogs.push( + color.yellow( + "AndroidManifest.xml already sets android:networkSecurityConfig — make sure it permits cleartext traffic to 10.0.2.2 and 127.0.0.1 for test runs.", + ), + ); + return; + } + + const xmlDirectory = path.join( + this.$projectData.appResourcesDirectoryPath, + "Android", + "src", + "main", + "res", + "xml", + ); + this.$fs.ensureDirectoryExists(xmlDirectory); + const securityConfigPath = path.join(xmlDirectory, "network_security.xml"); + if (!this.$fs.exists(securityConfigPath)) { + this.$fs.copyFile( + this.$resources.resolvePath("test/network_security.xml"), + securityConfigPath, + ); + bufferedLogs.push( + `Added ${color.yellow("App_Resources/Android/src/main/res/xml/network_security.xml")}`, + ); + } + + this.$fs.writeFile( + manifestPath, + manifestContent.replace( + / !moduleToInstall.projectType || - moduleToInstall.projectType === projectFilesExtension + moduleToInstall.projectType === projectFilesExtension, ); for (const mod of modulesToInstall) { let moduleToInstall = mod.name; moduleToInstall += `@${mod.version}`; await this.$packageManager.install(moduleToInstall, projectDir, { - "save-dev": true, + // Packages with native code must land in "dependencies" — the CLI + // integrates plugin platform files (pods, aars) only from there. + ...(mod.saveInDependencies ? { save: true } : { "save-dev": true }), "save-exact": true, optional: false, disableNpmInstall: this.$options.disableNpmInstall, @@ -97,16 +162,40 @@ class TestInitCommand implements ICommand { const modulePackageJsonContent = this.$fs.readJson(modulePackageJsonPath); const modulePeerDependencies = modulePackageJsonContent.peerDependencies || {}; + const modulePeerDependenciesMeta = + modulePackageJsonContent.peerDependenciesMeta || {}; + const projectPackageJson = this.$fs.readJson( + path.join(projectDir, "package.json"), + ); + const installedProjectDependencies = { + ...projectPackageJson.dependencies, + ...projectPackageJson.devDependencies, + }; for (const peerDependency in modulePeerDependencies) { const isPeerDependencyExcluded = _.includes( mod.excludedPeerDependencies, - peerDependency + peerDependency, ); if (isPeerDependencyExcluded) { continue; } + if ( + modulePeerDependenciesMeta[peerDependency] && + modulePeerDependenciesMeta[peerDependency].optional + ) { + continue; + } + + // Reinstalling an already-declared package would move it to + // devDependencies — for packages with native code (e.g. + // @nativescript/core) that strips their platform files from the + // native build. + if (installedProjectDependencies[peerDependency]) { + continue; + } + const dependencyVersion = modulePeerDependencies[peerDependency] || "*"; // catch errors when a peerDependency is already installed @@ -122,7 +211,7 @@ class TestInitCommand implements ICommand { frameworkPath: this.$options.frameworkPath, ignoreScripts: this.$options.ignoreScripts, path: this.$options.path, - } + }, ); } catch (e) { this.$logger.error(e.message); @@ -130,10 +219,16 @@ class TestInitCommand implements ICommand { } } - await this.$pluginsService.add( - "@nativescript/unit-test-runner", - this.$projectData - ); + const isVitest = frameworkToInstall === "vitest"; + + if (!isVitest) { + // The Karma client only exists in the v4 line — v5+ is Vitest-only, so + // an unpinned install would break these setups once v5 is `latest`. + await this.$pluginsService.add( + "@nativescript/unit-test-runner@^4.0.0", + this.$projectData, + ); + } this.$logger.clearScreen(); @@ -142,11 +237,11 @@ class TestInitCommand implements ICommand { const testsDir = path.join(this.$projectData.appDirectoryPath, "tests"); const projectTestsDir = path.relative( this.$projectData.projectDir, - testsDir + testsDir, ); const relativeTestsDir = path.relative( this.$projectData.appDirectoryPath, - testsDir + testsDir, ); let shouldCreateSampleTests = true; if (this.$fs.exists(testsDir)) { @@ -157,80 +252,135 @@ class TestInitCommand implements ICommand { `Note: The "${projectTestsDir}" directory already exists, will not create example tests in the project.`, `You may create "${specFilenamePattern}" files anywhere you'd like.`, "", - ].join("\n") - ) + ].join("\n"), + ), ); shouldCreateSampleTests = false; } this.$fs.ensureDirectoryExists(testsDir); - const frameworks = [frameworkToInstall] - .concat(this.karmaConfigAdditionalFrameworks[frameworkToInstall] || []) - .map((fw) => `'${fw}'`) - .join(", "); - const testFiles = `'${fromWindowsRelativePathToUnix( - relativeTestsDir - )}/**/*${projectFilesExtension}'`; - const karmaConfTemplate = this.$resources.readText("test/karma.conf.js"); - const karmaConf = _.template(karmaConfTemplate)({ - frameworks, - testFiles, - basePath: this.$projectData.getAppDirectoryRelativePath(), - }); - - this.$fs.writeFile(path.join(projectDir, "karma.conf.js"), karmaConf); + if (isVitest) { + const vitestConfigResourcePath = this.$resources.resolvePath( + "test/vitest.config.mts", + ); + this.$fs.copyFile( + vitestConfigResourcePath, + path.join(projectDir, "vitest.config.mts"), + ); + bufferedLogs.push(`Added/replaced ${color.yellow("vitest.config.mts")}`); + this.ensureAndroidNetworkSecurityConfig(bufferedLogs); + } else { + const frameworks = [frameworkToInstall] + .concat(this.karmaConfigAdditionalFrameworks[frameworkToInstall] || []) + .map((fw) => `'${fw}'`) + .join(", "); + const testFiles = `'${fromWindowsRelativePathToUnix( + relativeTestsDir, + )}/**/*${projectFilesExtension}'`; + const karmaConfTemplate = this.$resources.readText("test/karma.conf.js"); + const karmaConf = _.template(karmaConfTemplate)({ + frameworks, + testFiles, + basePath: this.$projectData.getAppDirectoryRelativePath(), + }); + + this.$fs.writeFile(path.join(projectDir, "karma.conf.js"), karmaConf); + } const exampleFilePath = this.$resources.resolvePath( - `test/example.${frameworkToInstall}${projectFilesExtension}` + `test/example.${frameworkToInstall}${projectFilesExtension}`, ); const targetExampleTestPath = path.join( testsDir, - `example.spec${projectFilesExtension}` + `example.spec${projectFilesExtension}`, ); if (shouldCreateSampleTests && this.$fs.exists(exampleFilePath)) { this.$fs.copyFile(exampleFilePath, targetExampleTestPath); const targetExampleTestRelativePath = path.relative( projectDir, - targetExampleTestPath + targetExampleTestPath, ); bufferedLogs.push( - `Added example test: ${color.yellow(targetExampleTestRelativePath)}` + `Added example test: ${color.yellow(targetExampleTestRelativePath)}`, ); } // test main entry const testMainResourcesPath = this.$resources.resolvePath( - `test/test-main${projectFilesExtension}` + isVitest + ? `test/test-main.vitest${projectFilesExtension}` + : `test/test-main${projectFilesExtension}`, ); const testMainPath = path.join( this.$projectData.appDirectoryPath, - `test${projectFilesExtension}` + `test${projectFilesExtension}`, ); if (!this.$fs.exists(testMainPath)) { this.$fs.copyFile(testMainResourcesPath, testMainPath); const testMainRelativePath = path.relative(projectDir, testMainPath); bufferedLogs.push( - `Main test entrypoint created: ${color.yellow(testMainRelativePath)}` + `Main test entrypoint created: ${color.yellow(testMainRelativePath)}`, ); } - const testTsConfigTemplate = this.$resources.readText( - "test/tsconfig.spec.json" - ); - const testTsConfig = _.template(testTsConfigTemplate)({ - basePath: this.$projectData.getAppDirectoryRelativePath(), - }); + if (!isVitest || projectFilesExtension === ".ts") { + const testTsConfigTemplate = this.$resources.readText( + "test/tsconfig.spec.json", + ); + const testTsConfig = _.template(testTsConfigTemplate)({ + basePath: this.$projectData.getAppDirectoryRelativePath(), + }); - this.$fs.writeFile( - path.join(projectDir, "tsconfig.spec.json"), - testTsConfig - ); - bufferedLogs.push(`Added/replaced ${color.yellow("tsconfig.spec.json")}`); + this.$fs.writeFile( + path.join(projectDir, "tsconfig.spec.json"), + testTsConfig, + ); + bufferedLogs.push(`Added/replaced ${color.yellow("tsconfig.spec.json")}`); + } const greyDollarSign = color.grey("$"); + const closingNotes = isVitest + ? [ + color.yellow( + `Note: emulator/simulator test runs connect over the local loopback. When testing on a physical Android device, keep it connected over USB (adb reverse is set up automatically); for a physical iOS or visionOS device, pass a reachable 'url' to the coordinator in your test entry.`, + ), + "", + "", + `You can now run your tests:`, + "", + ` ${greyDollarSign} ${color.green("ns test ios")}`, + ` ${greyDollarSign} ${color.green("ns test android")}`, + ` ${greyDollarSign} ${color.green("ns test visionos")}`, + "", + `or directly through Vitest (editor extensions, CI):`, + "", + ` ${greyDollarSign} ${color.green("NS_PLATFORM=ios npx vitest run")}`, + "", + ] + : [ + color.yellow( + `Note: @nativescript/unit-test-runner was included in "dependencies" as a convenience to automatically adjust your app's Info.plist on iOS and AndroidManifest.xml on Android to ensure the socket connects properly.`, + ), + "", + color.yellow( + `For production you may want to move to "devDependencies" and manage the settings yourself.`, + ), + "", + color.yellow( + `Karma-based unit testing is deprecated and will be removed in a future release. Consider '$ ns test init --framework vitest'.`, + ), + "", + "", + `You can now run your tests:`, + "", + ` ${greyDollarSign} ${color.green("ns test ios")}`, + ` ${greyDollarSign} ${color.green("ns test android")}`, + "", + ]; + this.$logger.info( [ [ @@ -241,21 +391,8 @@ class TestInitCommand implements ICommand { "", ...bufferedLogs, "", - color.yellow( - `Note: @nativescript/unit-test-runner was included in "dependencies" as a convenience to automatically adjust your app's Info.plist on iOS and AndroidManifest.xml on Android to ensure the socket connects properly.` - ), - "", - color.yellow( - `For production you may want to move to "devDependencies" and manage the settings yourself.` - ), - "", - "", - `You can now run your tests:`, - "", - ` ${greyDollarSign} ${color.green("ns test ios")}`, - ` ${greyDollarSign} ${color.green("ns test android")}`, - "", - ].join("\n") + ...closingNotes, + ].join("\n"), ); } } diff --git a/lib/commands/test.ts b/lib/commands/test.ts index 0f51e2b8cb..646af87e3e 100644 --- a/lib/commands/test.ts +++ b/lib/commands/test.ts @@ -3,7 +3,11 @@ import { ANDROID_RELEASE_BUILD_ERROR_MESSAGE, ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE, } from "../constants"; -import { IProjectData, ITestExecutionService } from "../definitions/project"; +import { + IProjectData, + ITestExecutionService, + IVitestExecutionService, +} from "../definitions/project"; import { IOptions } from "../declarations"; import { IPlatformEnvironmentRequirements } from "../definitions/platform"; import { IMigrateController } from "../definitions/migrate"; @@ -26,6 +30,7 @@ abstract class TestCommandBase { protected abstract platform: string; protected abstract $projectData: IProjectData; protected abstract $testExecutionService: ITestExecutionService; + protected abstract $vitestExecutionService: IVitestExecutionService; protected abstract $analyticsService: IAnalyticsService; protected abstract $options: IOptions; protected abstract $platformEnvironmentRequirements: IPlatformEnvironmentRequirements; @@ -34,8 +39,22 @@ abstract class TestCommandBase { protected abstract $liveSyncCommandHelper: ILiveSyncCommandHelper; protected abstract $devicesService: Mobile.IDevicesService; protected abstract $migrateController: IMigrateController; + protected abstract $logger: ILogger; public async execute(args: string[]): Promise { + if (this.$vitestExecutionService.isVitestProject(this.$projectData)) { + await this.$vitestExecutionService.startTestRun( + this.platform, + this.$projectData, + ); + process.exit(0); + } + + this.$logger.warn( + "Karma-based unit testing is deprecated and will be removed in a future release. " + + "Re-initialize your tests with '$ ns test init --framework vitest' to migrate.", + ); + let devices = []; if (this.$options.debugBrk) { await this.$devicesService.initialize({ @@ -124,6 +143,20 @@ abstract class TestCommandBase { options: this.$options, }); + if (this.$vitestExecutionService.isVitestProject(this.$projectData)) { + const canStartTestRun = this.$vitestExecutionService.canStartTestRun( + this.$projectData, + ); + if (!canStartTestRun) { + this.$errors.fail({ + formatStr: + "Error: In order to run unit tests, your project must already be configured by running $ ns test init.", + errorCode: ErrorCodes.TESTS_INIT_REQUIRED, + }); + } + return output.canExecute && canStartTestRun; + } + const canStartKarmaServer = await this.$testExecutionService.canStartKarmaServer(this.$projectData); if (!canStartKarmaServer) { @@ -144,6 +177,7 @@ class TestAndroidCommand extends TestCommandBase implements ICommand { constructor( protected $projectData: IProjectData, protected $testExecutionService: ITestExecutionService, + protected $vitestExecutionService: IVitestExecutionService, protected $analyticsService: IAnalyticsService, protected $options: IOptions, protected $platformEnvironmentRequirements: IPlatformEnvironmentRequirements, @@ -152,6 +186,7 @@ class TestAndroidCommand extends TestCommandBase implements ICommand { protected $liveSyncCommandHelper: ILiveSyncCommandHelper, protected $devicesService: Mobile.IDevicesService, protected $migrateController: IMigrateController, + protected $logger: ILogger, ) { super(); } @@ -185,6 +220,7 @@ class TestIosCommand extends TestCommandBase implements ICommand { constructor( protected $projectData: IProjectData, protected $testExecutionService: ITestExecutionService, + protected $vitestExecutionService: IVitestExecutionService, protected $analyticsService: IAnalyticsService, protected $options: IOptions, protected $platformEnvironmentRequirements: IPlatformEnvironmentRequirements, @@ -193,10 +229,62 @@ class TestIosCommand extends TestCommandBase implements ICommand { protected $liveSyncCommandHelper: ILiveSyncCommandHelper, protected $devicesService: Mobile.IDevicesService, protected $migrateController: IMigrateController, + protected $logger: ILogger, ) { super(); } } +class TestVisionOSCommand extends TestIosCommand { + protected platform = "visionOS"; + + // The injector discovers dependencies by parsing constructor source text, + // so an inherited constructor would resolve to zero dependencies. + constructor( + protected $projectData: IProjectData, + protected $testExecutionService: ITestExecutionService, + protected $vitestExecutionService: IVitestExecutionService, + protected $analyticsService: IAnalyticsService, + protected $options: IOptions, + protected $platformEnvironmentRequirements: IPlatformEnvironmentRequirements, + protected $errors: IErrors, + protected $cleanupService: ICleanupService, + protected $liveSyncCommandHelper: ILiveSyncCommandHelper, + protected $devicesService: Mobile.IDevicesService, + protected $migrateController: IMigrateController, + protected $logger: ILogger, + ) { + super( + $projectData, + $testExecutionService, + $vitestExecutionService, + $analyticsService, + $options, + $platformEnvironmentRequirements, + $errors, + $cleanupService, + $liveSyncCommandHelper, + $devicesService, + $migrateController, + $logger, + ); + } + + async canExecute(args: string[]): Promise { + this.$projectData.initializeProjectData(); + // The Karma runner (v4 line) never supported visionOS — only the Vitest + // path can drive it. + if (!this.$vitestExecutionService.isVitestProject(this.$projectData)) { + this.$errors.fail( + "visionOS unit testing requires the Vitest runner. Run '$ ns test init --framework vitest' to configure your project.", + ); + } + + return super.canExecute(args); + } +} + injector.registerCommand("test|android", TestAndroidCommand); injector.registerCommand("test|ios", TestIosCommand); +injector.registerCommand("test|vision", TestVisionOSCommand); +injector.registerCommand("test|visionos", TestVisionOSCommand); diff --git a/lib/common/declarations.d.ts b/lib/common/declarations.d.ts index 8100843aca..8a31be7da6 100644 --- a/lib/common/declarations.d.ts +++ b/lib/common/declarations.d.ts @@ -1552,6 +1552,12 @@ interface IDependencyInformation { version?: string; projectType?: string; excludedPeerDependencies?: string[]; + /** + * Install into "dependencies" instead of "devDependencies". Required for + * packages with native code — the CLI integrates plugin platform files + * (pods, aars) only for regular dependencies. + */ + saveInDependencies?: boolean; } /** diff --git a/lib/constants.ts b/lib/constants.ts index 1c50e69870..7ac794cee0 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -35,7 +35,7 @@ export const ANDROID_DEVICE_APP_ROOT_TEMPLATE = `/data/data/%s/files`; export const NODE_MODULE_CACHE_PATH_KEY_NAME = "node-modules-cache-path"; export const DEFAULT_APP_IDENTIFIER_PREFIX = "org.nativescript"; export const LIVESYNC_EXCLUDED_DIRECTORIES = ["app_resources"]; -export const TESTING_FRAMEWORKS = ["jasmine", "mocha", "qunit"]; +export const TESTING_FRAMEWORKS = ["vitest", "jasmine", "mocha", "qunit"]; export const TEST_RUNNER_NAME = "@nativescript/unit-test-runner"; export const LIVESYNC_EXCLUDED_FILE_PATTERNS = ["**/*.js.map", "**/*.ts"]; export const XML_FILE_EXTENSION = ".xml"; @@ -363,9 +363,7 @@ export const enum PlatformTypes { } export type SupportedPlatform = - | PlatformTypes.ios - | PlatformTypes.android - | PlatformTypes.visionos; + PlatformTypes.ios | PlatformTypes.android | PlatformTypes.visionos; export const PODFILE_NAME = "Podfile"; diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index 4dd9a85a6a..598a05130c 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -683,6 +683,12 @@ interface ITestExecutionService { canStartKarmaServer(projectData: IProjectData): Promise; } +interface IVitestExecutionService { + isVitestProject(projectData: IProjectData): boolean; + canStartTestRun(projectData: IProjectData): boolean; + startTestRun(platform: string, projectData: IProjectData): Promise; +} + interface ITestInitializationService { getDependencies(framework: string): IDependencyInformation[]; getFrameworkNames(): string[]; diff --git a/lib/services/test-initialization-service.ts b/lib/services/test-initialization-service.ts index 166ca77da5..ba70e1c051 100644 --- a/lib/services/test-initialization-service.ts +++ b/lib/services/test-initialization-service.ts @@ -13,38 +13,48 @@ import { injector } from "../common/yok"; export class TestInitializationService implements ITestInitializationService { private configsPath = path.join(__dirname, "..", "..", "config"); - constructor(private $errors: IErrors, private $fs: IFileSystem) {} + constructor( + private $errors: IErrors, + private $fs: IFileSystem, + ) {} @cache() public getDependencies(selectedFramework: string): IDependencyInformation[] { const dependenciesPath = path.join( this.configsPath, - "test-dependencies.json" + "test-dependencies.json", ); const allDependencies: { name: string; framework?: string; + frameworks?: string[]; excludedPeerDependencies?: string[]; }[] = this.$fs.readJson(dependenciesPath); const dependenciesVersionsPath = path.join( this.configsPath, - "test-deps-versions-generated.json" + "test-deps-versions-generated.json", ); const dependenciesVersions = this.$fs.readJson(dependenciesVersionsPath); - const targetFrameworkDependencies: IDependencyInformation[] = allDependencies - .filter( - (dependency) => - !dependency.framework || dependency.framework === selectedFramework - ) - .map((dependency) => { - const dependencyVersion = dependenciesVersions[dependency.name]; - if (!dependencyVersion) { - this.$errors.fail(`'${dependency}' is not a registered dependency.`); - } - return { ...dependency, version: dependencyVersion }; - }); + const targetFrameworkDependencies: IDependencyInformation[] = + allDependencies + .filter( + (dependency) => + dependency.framework === selectedFramework || + (dependency.frameworks && + dependency.frameworks.indexOf(selectedFramework) !== -1) || + (!dependency.framework && !dependency.frameworks), + ) + .map((dependency) => { + const dependencyVersion = dependenciesVersions[dependency.name]; + if (!dependencyVersion) { + this.$errors.fail( + `'${dependency}' is not a registered dependency.`, + ); + } + return { ...dependency, version: dependencyVersion }; + }); return targetFrameworkDependencies; } @@ -56,12 +66,18 @@ export class TestInitializationService implements ITestInitializationService { public getFrameworkNames(): string[] { const configsPath = path.join(__dirname, "..", "..", "config"); const dependenciesPath = path.join(configsPath, "test-dependencies.json"); - const allDependencies: { name: string; framework?: string }[] = JSON.parse( - fs.readFileSync(dependenciesPath, { encoding: "utf-8" }) + const allDependencies: { + name: string; + framework?: string; + frameworks?: string[]; + }[] = JSON.parse(fs.readFileSync(dependenciesPath, { encoding: "utf-8" })); + const frameworks = _.uniq( + _.flatten( + allDependencies.map( + (item) => item.frameworks || (item.framework ? [item.framework] : []), + ), + ), ); - const frameworks = _.uniqBy(allDependencies, "framework") - .map((item) => item && item.framework) - .filter((item) => item); return frameworks; } diff --git a/lib/services/vitest-execution-service.ts b/lib/services/vitest-execution-service.ts new file mode 100644 index 0000000000..f4811780e8 --- /dev/null +++ b/lib/services/vitest-execution-service.ts @@ -0,0 +1,95 @@ +import * as path from "path"; +import { IProjectData, IVitestExecutionService } from "../definitions/project"; +import { IOptions } from "../declarations"; +import { IChildProcess, IErrors, IFileSystem } from "../common/declarations"; +import { injector } from "../common/yok"; +import { resolvePackagePath } from "../helpers/package-path-helper"; + +const VITEST_CONFIG_FILES = [ + "vitest.config.mts", + "vitest.config.ts", + "vitest.config.mjs", + "vitest.config.js", +]; + +export class VitestExecutionService implements IVitestExecutionService { + constructor( + private $childProcess: IChildProcess, + private $errors: IErrors, + private $fs: IFileSystem, + private $logger: ILogger, + private $options: IOptions, + ) {} + + public isVitestProject(projectData: IProjectData): boolean { + return !!this.getConfigPath(projectData); + } + + public canStartTestRun(projectData: IProjectData): boolean { + return ( + this.isVitestProject(projectData) && + !!resolvePackagePath("vitest", { paths: [projectData.projectDir] }) + ); + } + + public async startTestRun( + platform: string, + projectData: IProjectData, + ): Promise { + const vitestPackagePath = resolvePackagePath("vitest", { + paths: [projectData.projectDir], + }); + if (!vitestPackagePath) { + this.$errors.fail( + "Unable to find 'vitest' in the project. Run '$ ns test init --framework vitest' first.", + ); + } + + if (this.$options.watch) { + this.$logger.warn( + "'--watch' is not supported for on-device Vitest runs yet; running once.", + ); + } + + const args = [path.join(vitestPackagePath, "vitest.mjs"), "run"]; + if (this.$options.env && this.$options.env.codeCoverage) { + args.push("--coverage"); + } + + const env: NodeJS.ProcessEnv = { + ...process.env, + NS_PLATFORM: platform.toLowerCase(), + }; + if (this.$options.device) { + env.NS_DEVICE = this.$options.device; + } + + const result = await this.$childProcess.spawnFromEvent( + process.execPath, + args, + "close", + { + cwd: projectData.projectDir, + stdio: "inherit", + env, + }, + { throwError: false }, + ); + + if (result.exitCode !== 0) { + this.$errors.fail("Test run failed."); + } + } + + private getConfigPath(projectData: IProjectData): string { + for (const configFile of VITEST_CONFIG_FILES) { + const configPath = path.join(projectData.projectDir, configFile); + if (this.$fs.exists(configPath)) { + return configPath; + } + } + return null; + } +} + +injector.register("vitestExecutionService", VitestExecutionService); diff --git a/resources/test/example.vitest.js b/resources/test/example.vitest.js new file mode 100644 index 0000000000..fc30d00960 --- /dev/null +++ b/resources/test/example.vitest.js @@ -0,0 +1,7 @@ +import { describe, expect, it } from 'vitest'; + +describe('example', () => { + it('runs on the device', () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/resources/test/example.vitest.ts b/resources/test/example.vitest.ts new file mode 100644 index 0000000000..d512dc5bf0 --- /dev/null +++ b/resources/test/example.vitest.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from "vitest"; + +describe("example", () => { + it("runs on the device", () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/resources/test/network_security.xml b/resources/test/network_security.xml new file mode 100644 index 0000000000..96fb169145 --- /dev/null +++ b/resources/test/network_security.xml @@ -0,0 +1,10 @@ + + + + + localhost + 127.0.0.1 + 10.0.2.2 + + diff --git a/resources/test/test-main.vitest.js b/resources/test/test-main.vitest.js new file mode 100644 index 0000000000..6c5da46172 --- /dev/null +++ b/resources/test/test-main.vitest.js @@ -0,0 +1,19 @@ +import '@valor/nativescript-websockets'; +import { Application } from '@nativescript/core'; +import { + NativeScriptVitestCoordinator, + createWebpackTestRegistry, +} from '@nativescript/unit-test-runner/runtime'; +import { createVitestHostPage } from '@nativescript/unit-test-runner/testing'; + +const coordinator = new NativeScriptVitestCoordinator({ + // Every spec matched by the Vitest `include` patterns must also be matched + // here, or the device will not be able to load it. + registry: createWebpackTestRegistry( + require.context('./', true, /\.spec\.js$/) + ), +}); + +void coordinator.start(); +// The host page keeps the screen free as a mount() surface for UI specs. +Application.run({ create: () => createVitestHostPage(coordinator) }); diff --git a/resources/test/test-main.vitest.ts b/resources/test/test-main.vitest.ts new file mode 100644 index 0000000000..d15c5c9db0 --- /dev/null +++ b/resources/test/test-main.vitest.ts @@ -0,0 +1,21 @@ +import "@valor/nativescript-websockets"; +import { Application } from "@nativescript/core"; +import { + NativeScriptVitestCoordinator, + createWebpackTestRegistry, +} from "@nativescript/unit-test-runner/runtime"; +import { createVitestHostPage } from "@nativescript/unit-test-runner/testing"; + +declare let require: any; + +const coordinator = new NativeScriptVitestCoordinator({ + // Every spec matched by the Vitest `include` patterns must also be matched + // here, or the device will not be able to load it. + registry: createWebpackTestRegistry( + require.context("./", true, /\.spec\.ts$/), + ), +}); + +void coordinator.start(); +// The host page keeps the screen free as a mount() surface for UI specs. +Application.run({ create: () => createVitestHostPage(coordinator) }); diff --git a/resources/test/vitest.config.mts b/resources/test/vitest.config.mts new file mode 100644 index 0000000000..51dec4f8aa --- /dev/null +++ b/resources/test/vitest.config.mts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; +import { nativeScript } from '@nativescript/unit-test-runner'; + +// Platform is selected per run: `ns test ios` / `ns test android` / +// `ns test visionos`, or NS_PLATFORM=ios npx vitest run +export default defineConfig({ + plugins: [ + nativeScript({ + platform: process.env.NS_PLATFORM || 'ios', // 'android' | 'ios' | 'visionos' + device: process.env.NS_DEVICE || undefined, + }), + ], + test: { + // Device runs include app startup and real layout passes. + testTimeout: 30_000, + }, +});