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');
+ }
+}