diff --git a/src/Console/bin/bones b/src/Console/bin/bones index a0dd969..152c036 100644 --- a/src/Console/bin/bones +++ b/src/Console/bin/bones @@ -1675,6 +1675,46 @@ namespace Bones { return $other ? $stubs : ['/' . self::PLUGIN_STUBS]; } + /** + * The JavaScript tests that sit beside the code they test, found the way the jest.config.js + * written by `migrate:to-v2` finds them: every `__tests__` folder, and every `*.test.*` or + * `*.spec.*` script, with the `__snapshots__` and `__mocks__` folders Jest keeps beside them. + * They live in resources/assets, which `--wp` ships. The skip list matches exact paths, so + * each one is listed where it is. + * + * vendor/ and node_modules/ are not walked: they are not the plugin's own code. Neither is a + * folder that cannot be read, which the copy could not read either. + * + * @since 3.2.0 + * @param string $relative The folder to walk, relative to the plugin root. + * @return string[] Entries of the deploy skip list. + */ + protected function javascriptTestsToSkip(string $relative = ''): array + { + $entries = []; + + foreach (scandir(__DIR__ . $relative) ?: [] as $name) { + // The deploy never copies a dot entry, so there is nothing to list in one. + if (strpos($name, '.') === 0 || in_array($name, ['vendor', 'node_modules'], true)) { + continue; + } + + $path = "{$relative}/{$name}"; + + if (is_dir(__DIR__ . $path)) { + if (in_array($name, ['__tests__', '__snapshots__', '__mocks__'], true)) { + $entries[] = $path; + } elseif (!is_link(__DIR__ . $path) && is_readable(__DIR__ . $path)) { + $entries = array_merge($entries, $this->javascriptTestsToSkip($path)); + } + } elseif (preg_match('/\.(test|spec)\.(ts|tsx|js|jsx)$/', $name)) { + $entries[] = $path; + } + } + + return $entries; + } + /** * Return true if the command is available in the system. * @@ -2719,6 +2759,14 @@ namespace Bones { '/phpcs.xml.dist', '/mix-manifest.json', '/release.sh', + // The plugin's test suites: PHPUnit's, where PHPUnit looks for it, and Jest's. + '/tests', + '/phpunit.xml', + '/phpunit.xml.dist', + '/phpunit.dist.xml', + '/jest.config.js', + '/jest.config.json', + ...$this->javascriptTestsToSkip(), ...$this->pluginStubsToSkip(), ]); @@ -2759,6 +2807,8 @@ namespace Bones { $this->pruneDevPackages($path); } + $this->assertAutoloadedFilesShipped($path, $is_create_zip); + /** * Fires when the console deploy is completed. * @@ -3110,6 +3160,47 @@ namespace Bones { } } + /** + * Stop when the package's Composer autoloader requires a file the package does not hold. + * + * Composer requires its `files` entries on every request, so one that points at a path the + * deploy left out is a fatal error the moment the plugin loads. It happens when autoload-dev + * names a file in `tests/` and the dev autoloader stays: with --keep-dev, or when no dev + * package is installed and so none is pruned and the autoloader is not rebuilt. + * + * @since 3.2.0 + * @param string $path The deployed copy. + * @param bool $isTemporary True when the copy is the folder a zip is made from. + */ + protected function assertAutoloadedFilesShipped(string $path, bool $isTemporary): void + { + $map = "{$path}/vendor/composer/autoload_files.php"; + + if (!is_file($map)) { + return; + } + + // The map works out its paths from where it sits, so in the copy they point into the copy. + $files = (static fn() => require $map)(); + $missing = array_filter(is_array($files) ? $files : [], fn($file) => !is_file($file)); + + if (empty($missing)) { + return; + } + + $root = (realpath($path) ?: $path) . '/'; + $list = implode(', ', array_map(fn($file) => str_replace($root, '', $file), $missing)); + + if ($isTemporary) { + $this->deleteDirectory($path); + } + + $this->error( + "The package's Composer autoloader requires files the deploy left out ({$list}), so the plugin would stop with a fatal error as it loads. Move them out of composer.json's autoload-dev, or keep them in the package with the wpbones_console_deploy_default_skip_files_folders filter.", + ); + exit(1); + } + /** * The command that runs Composer: a composer.phar in the plugin root, else `composer` on PATH. */ diff --git a/tests/Console/DeployTestSuitesTest.php b/tests/Console/DeployTestSuitesTest.php new file mode 100644 index 0000000..1308a31 --- /dev/null +++ b/tests/Console/DeployTestSuitesTest.php @@ -0,0 +1,165 @@ +bones = new BonesProcess(); + $this->target = $this->bones->root . '/out'; + + foreach ( + [ + 'plugin/Http/Controllers/TestController.php' => " " " "\n", + 'phpunit.xml.dist' => "\n", + 'phpunit.dist.xml' => "\n", + 'jest.config.js' => "module.exports = {};\n", + 'jest.config.json' => "{}\n", + 'resources/assets/apps/components/Header.tsx' => "export {};\n", + 'resources/assets/apps/components/__tests__/Header.test.tsx' => "test('x', () => {});\n", + 'resources/assets/apps/components/__tests__/fixtures/data.json' => "{}\n", + 'resources/assets/apps/utils/format.ts' => "export {};\n", + 'resources/assets/apps/utils/format.test.ts' => "test('x', () => {});\n", + 'resources/assets/apps/utils/format.spec.js' => "test('x', () => {});\n", + 'resources/assets/apps/utils/latest.ts' => "export {};\n", + 'resources/assets/apps/utils/__snapshots__/format.test.ts.snap' => "// snapshot\n", + 'resources/assets/apps/utils/__mocks__/api.ts' => "export {};\n", + '__mocks__/fs.js' => "module.exports = {};\n", + 'vendor/acme/lib/__tests__/lib.test.js' => "test('x', () => {});\n", + 'vendor/acme/lib/functions.php' => " " \$vendorDir . '/acme/lib/functions.php',\n);\n", + ] + as $file => $content + ) { + $path = $this->bones->plugin . '/' . $file; + + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0777, true); + } + + file_put_contents($path, $content); + } + } + + protected function tearDown(): void + { + $this->bones->remove(); + + parent::tearDown(); + } + + /** @return array{status:int, output:string} */ + private function deploy(string ...$options): array + { + return $this->bones->run(['deploy', $this->target, '--no-build', ...$options]); + } + + public function test_the_php_suite_does_not_reach_the_package(): void + { + $result = $this->deploy(); + + $this->assertSame(0, $result['status'], $result['output']); + $this->assertFileExists($this->target . '/wp-kirk.php', 'the deploy did not run'); + $this->assertDirectoryDoesNotExist($this->target . '/tests'); + $this->assertFileDoesNotExist($this->target . '/phpunit.xml'); + $this->assertFileDoesNotExist($this->target . '/phpunit.xml.dist'); + $this->assertFileDoesNotExist($this->target . '/phpunit.dist.xml'); + } + + public function test_the_javascript_tests_do_not_reach_a_wordpress_org_package(): void + { + $result = $this->deploy('--wp'); + $assets = $this->target . '/resources/assets/apps'; + + $this->assertSame(0, $result['status'], $result['output']); + $this->assertFileExists($assets . '/components/Header.tsx', '--wp did not keep resources/assets'); + $this->assertFileDoesNotExist($this->target . '/jest.config.js'); + $this->assertFileDoesNotExist($this->target . '/jest.config.json'); + $this->assertDirectoryDoesNotExist($assets . '/components/__tests__'); + $this->assertFileDoesNotExist($assets . '/utils/format.test.ts'); + $this->assertFileDoesNotExist($assets . '/utils/format.spec.js'); + $this->assertDirectoryDoesNotExist($assets . '/utils/__snapshots__'); + $this->assertDirectoryDoesNotExist($assets . '/utils/__mocks__'); + $this->assertDirectoryDoesNotExist($this->target . '/__mocks__'); + } + + public function test_an_autoloaded_file_the_deploy_left_out_stops_it(): void + { + // autoload-dev "files" pointing into tests/: the dev autoloader stays when nothing is pruned + // (no dev package installed, as here, or --keep-dev), and it would require a missing file + // on every request. + file_put_contents($this->bones->plugin . '/tests/helpers.php', "bones->plugin . '/vendor/composer/autoload_files.php', + " \$vendorDir . '/acme/lib/functions.php',\n 'b2' => \$baseDir . '/tests/helpers.php',\n);\n" + ); + + $result = $this->deploy(); + + $this->assertNotSame(0, $result['status'], 'a package that cannot load was reported as deployed'); + $this->assertStringContainsString('tests/helpers.php', $result['output']); + $this->assertStringNotContainsString('acme/lib/functions.php', $result['output']); + } + + public function test_an_unreadable_folder_is_not_walked_noisily(): void + { + mkdir($this->bones->plugin . '/assets/private', 0777, true); + chmod($this->bones->plugin . '/assets/private', 0); + + try { + $result = $this->deploy(); + } finally { + chmod($this->bones->plugin . '/assets/private', 0755); + } + + $this->assertSame(0, $result['status'], $result['output']); + $this->assertStringNotContainsString('Warning', $result['output']); + } + + public function test_the_code_beside_the_tests_still_ships(): void + { + $result = $this->deploy('--wp'); + + $this->assertSame(0, $result['status'], $result['output']); + $this->assertFileExists($this->target . '/plugin/Http/Controllers/TestController.php'); + $this->assertFileExists($this->target . '/resources/assets/apps/utils/format.ts'); + $this->assertFileExists($this->target . '/resources/assets/apps/utils/latest.ts'); + } + + public function test_vendor_is_left_as_composer_installed_it(): void + { + $result = $this->deploy(); + + $this->assertSame(0, $result['status'], $result['output']); + $this->assertFileExists($this->target . '/vendor/acme/lib/__tests__/lib.test.js'); + } +}