diff --git a/docs/reference/extensions/express-framework.rst b/docs/reference/extensions/express-framework.rst index 5ecac734f..a0a78c078 100644 --- a/docs/reference/extensions/express-framework.rst +++ b/docs/reference/extensions/express-framework.rst @@ -10,8 +10,13 @@ The Express extension streamlines the process of building Express application rocks. It facilitates the installation of Express application dependencies, including -Node.js and npm, inside the rock. Additionally, it transfers your project files -to ``/app`` within the rock. +Node.js and npm, inside the rock. Extension discovers location of the +``package.json``, but can only package a single application. If application +defines ``build`` script, development dependencies will be installed, +``npm run build`` called, and entries matching ``file`` array (excluding +entries from ``.npmignore``, if exists) will be packages. If ``files`` +array is not defined and ``.npmignore`` does not exist, only files from +the ``dist/`` directory will be packaged. By default, the system foundation, or base, is set as ``bare`` to generate a lightweight image. @@ -23,14 +28,19 @@ bases. Project requirements -------------------- -There are 3 requirements to be able to use the ``expressjs-framework`` +There are two requirements to be able to use the ``expressjs-framework`` extension: -1. The application should reside in the ``app`` directory. -2. The application should have a ``package.json`` file. -3. The ``package.json`` file should define the ``start`` script. +1. The application should have a ``package.json`` file. +2. The ``package.json`` file should define the ``start`` script. For more information, see the `npm documentation `_. +If application defines ``build`` script in ``package.json`` file, it is +recommended to have ``files`` array describing the entries to be included +or have an appropriate ``.npmignore`` file to exclude entries not required +at the run time. If ``files`` array is not defined and ``.npmignore`` +does not exist, only the ``dist/`` directory will be packaged. + .. _reference-express-framework-npm-include-node: Node.js version diff --git a/rockcraft/extensions/expressjs.py b/rockcraft/extensions/expressjs.py index c8da65614..742b7a57e 100644 --- a/rockcraft/extensions/expressjs.py +++ b/rockcraft/extensions/expressjs.py @@ -17,8 +17,10 @@ """An extension for the NodeJS based Javascript application extension.""" import json +from pathlib import Path from typing import Any, cast +import craft_cli from typing_extensions import override from rockcraft.errors import ExtensionError @@ -144,19 +146,8 @@ def _gen_install_app_part(self) -> dict[str, Any]: """ install_app_part: dict[str, Any] = { "plugin": "npm", - "source": f"{self.IMAGE_BASE_DIR}/", - "override-build": ( - "rm -rf node_modules\n" - "craftctl default\n" - "npm config set script-shell=bash --location project\n" - "cp ${CRAFT_PART_BUILD}/.npmrc ${CRAFT_PART_INSTALL}/lib/node_modules/" - f"{self._app_name}/.npmrc\n" - # we can not user `permissions` block here because it doesn't work with symlinks - # bug: https://github.com/canonical/rockcraft/issues/660 - f"chown -R {USER_UID}:{USER_UID} ${{CRAFT_PART_INSTALL}}/lib/node_modules/{self._app_name}\n" - f"ln -s /lib/node_modules/{self._app_name} ${{CRAFT_PART_INSTALL}}/app\n" - f"chown -R {USER_UID}:{USER_UID} ${{CRAFT_PART_INSTALL}}/app\n" - ), + "source": f"{self._source_root}/", + "override-build": self._gen_override_build(), } if self._rock_base == "bare": install_app_part["override-build"] = ( @@ -184,6 +175,41 @@ def _gen_install_app_part(self) -> dict[str, Any]: install_app_part["build-environment"] = [{"UV_USE_IO_URING": "0"}] return install_app_part + def _gen_override_build(self) -> str: + """Generate the override-build script snippet. + + Script consists of three parts: + - lines before 'craftctl default' + - 'craftctl default' invocation + - lines after 'craftctl default' + """ + return self._gen_shell_script( + self._gen_override_build_pre_default(), + ["craftctl default"], + self._gen_override_build_post_default(), + ) + + def _gen_override_build_pre_default(self) -> list[str]: + """Generate override-build script part used before 'craftctl default'.""" + return ["rm -rf node_modules"] + + def _gen_override_build_post_default(self) -> list[str]: + """Generate override-build script part used after 'craftctl default'.""" + return [ + "npm config set script-shell=bash --location project", + "cp ${CRAFT_PART_BUILD}/.npmrc ${CRAFT_PART_INSTALL}/lib/node_modules/" + f"{self._app_name}/.npmrc", + # we can not user `permissions` block here because it doesn't work with symlinks + # bug: https://github.com/canonical/rockcraft/issues/660 + f"chown -R {USER_UID}:{USER_UID} ${{CRAFT_PART_INSTALL}}/lib/node_modules/{self._app_name}", + f"ln -s /lib/node_modules/{self._app_name} ${{CRAFT_PART_INSTALL}}/app", + f"chown -R {USER_UID}:{USER_UID} ${{CRAFT_PART_INSTALL}}/app", + ] + + def _gen_shell_script(self, *snippets: list[str]) -> str: + """Generate multiline shell script from lists of strings.""" + return "\n".join(line for fragment in snippets for line in fragment) + "\n" + def _gen_app_build_packages(self) -> list[str]: """Return the build packages for the install app part.""" if self._user_npm_include_node: @@ -226,6 +252,11 @@ def _rock_base(self) -> str: """Return the base of the rockcraft project.""" return self.yaml_data["base"] + @property + def _source_root(self) -> str: + """Return relative path to the source tree root directory.""" + return self.IMAGE_BASE_DIR + @property def _app_package_json(self) -> dict[str, Any]: """Return the app package.json contents.""" @@ -268,6 +299,13 @@ class ExpressJSFrameworkV2(ExpressJSFramework): supported base and experimental status differs. """ + PACKAGE_JSON = "package.json" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._package_json_file_cache = None + self._app_package_json_cache = None + @staticmethod @override def get_supported_bases() -> tuple[str, ...]: @@ -283,5 +321,138 @@ def is_experimental(base: str | None) -> bool: """ return True + def _gen_override_build_pre_default(self) -> list[str]: + """Generate override-build script part used before 'craftctl default'.""" + override_build = ["rm -rf node_modules"] + if self._has_build_script: + if not self._has_defined_files: + craft_cli.emit.warning( + "No .npmignore or '.files[]' in package.json, all files will be packaged" + ) + craft_cli.emit.warning( + "This may not be what you want to pack after running 'npm run build'" + ) + override_build.extend( + [ + "npm install --include=dev", + "npm run build", + ] + ) + # Note: there is no need to cleanup node_modules locally (like 'npm prune --omit=dev') + # npm_plugin calls 'npm pack' and it always ignores node_modules + # see https://docs.npmjs.com/cli/v10/configuring-npm/package-json#files + # npm install will user package.json from tgz to obtain the list of modules later + return override_build + + def _gen_override_build_post_default(self) -> list[str]: + """Generate override-build script part used after 'craftctl default'.""" + override_build = [] + if self._has_build_script: # Q: and not self._has_defined_files: ? + override_build.extend( + [ + f"mkdir -p ${{CRAFT_PART_INSTALL}}/lib/node_modules/{self._app_name}/dist", + f"cp -r dist/. ${{CRAFT_PART_INSTALL}}/lib/node_modules/{self._app_name}/dist", + ] + ) + override_build.extend(super()._gen_override_build_post_default()) + return override_build + + @property + def _source_root(self) -> str: + """Return relative path to the source tree root directory.""" + parent = self._package_json_file.parent + if self.project_root == parent: + return "." + return str(parent.relative_to(self.project_root)) + + @property + def _package_json_file(self) -> Path: + """Return package.json path. + + Search for package.json in the project root directory first. + If not found, search non-hidden subdirectories exactly one level deep. + + Returns: + Path: path to the discovered package.json file; + + Raises: + ExtensionError If the file is missing entirely, or if multiple + package.json files are found in the subdirectories. + + """ + if self._package_json_file_cache is not None: + return self._package_json_file_cache + + root_package_json = self.project_root / self.PACKAGE_JSON + if root_package_json.is_file(): + self._package_json_file_cache = root_package_json + return root_package_json + + package_json = None + for path in self.project_root.glob(f"[!.]*/{self.PACKAGE_JSON}"): + if path.is_file(): + if package_json is not None: # more than one found: error + raise ExtensionError( + "multiple package.json files", + doc_slug="/reference/extensions/express-framework/#project-requirements", + logpath_report=False, + ) + package_json = path + + if package_json is None: + raise ExtensionError( + "missing package.json file", + doc_slug="/reference/extensions/express-framework/#project-requirements", + logpath_report=False, + ) + + self._package_json_file_cache = package_json + return package_json + + @property + def _app_package_json(self) -> dict[str, Any]: + """Return the app package.json contents.""" + if self._app_package_json_cache is not None: + return self._app_package_json_cache + + package_json_contents = self._package_json_file.read_text(encoding="utf-8") + try: + app_package_json = json.loads(package_json_contents) + if not isinstance(app_package_json, dict): + raise ExtensionError( + "invalid package.json file", + doc_slug="/reference/extensions/express-framework/#project-requirements", + logpath_report=False, + ) + except json.JSONDecodeError as exc: + raise ExtensionError( + "failed to parse package.json; it might contain invalid JSON", + doc_slug="/reference/extensions/express-framework/#project-requirements", + logpath_report=False, + ) from exc + else: + self._app_package_json_cache = cast(dict[str, Any], app_package_json) + return self._app_package_json_cache + + @property + def _app_name(self) -> str: + """Return the application name as defined on package.json.""" + return self._app_package_json["name"] + + @property + def _has_defined_files(self) -> bool: + """Check if package.json::files[] is defined or .npmignore exists.""" + return ( + "files" in self._app_package_json + or (self._package_json_file.parent / ".npmignore").is_file() + ) + + @property + def _has_build_script(self) -> bool: + return ( + "scripts" in self._app_package_json + and "build" in self._app_package_json["scripts"] + ) + ExpressJSFrameworkFactory = _FrameworkFactory(ExpressJSFramework, ExpressJSFrameworkV2) diff --git a/tests/spread/rockcraft/extension-expressjs-build/.npmignore b/tests/spread/rockcraft/extension-expressjs-build/.npmignore new file mode 100644 index 000000000..bba4af520 --- /dev/null +++ b/tests/spread/rockcraft/extension-expressjs-build/.npmignore @@ -0,0 +1,3 @@ +package-lock.json +src +*.yaml diff --git a/tests/spread/rockcraft/extension-expressjs-build/package.json b/tests/spread/rockcraft/extension-expressjs-build/package.json new file mode 100644 index 000000000..cb24a3513 --- /dev/null +++ b/tests/spread/rockcraft/extension-expressjs-build/package.json @@ -0,0 +1,18 @@ +{ + "name": "express-typescript-app", + "version": "1.0.0", + "description": "Core Express runtime with TypeScript development environment", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "express": "^5.0.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^20.14.9", + "typescript": "^5.5.2" + } +} diff --git a/tests/spread/rockcraft/extension-expressjs-build/src/index.ts b/tests/spread/rockcraft/extension-expressjs-build/src/index.ts new file mode 100644 index 000000000..73c4daaac --- /dev/null +++ b/tests/spread/rockcraft/extension-expressjs-build/src/index.ts @@ -0,0 +1,11 @@ +import express, { Request, Response, Express } from 'express'; + +const app: Express = express(); + +const port: number = 3000; + +app.get('/', (req: Request, res: Response) => { + res.send('Hello World!'); +}); + +app.listen(port, () => console.log(`Server listening on :${port}`)); diff --git a/tests/spread/rockcraft/extension-expressjs-build/task.yaml b/tests/spread/rockcraft/extension-expressjs-build/task.yaml new file mode 100644 index 000000000..fd3a9f822 --- /dev/null +++ b/tests/spread/rockcraft/extension-expressjs-build/task.yaml @@ -0,0 +1,97 @@ +summary: expressjs extension test + +kill-timeout: 45m +environment: + SCENARIO/bare: bare + SCENARIO/base_2604: ubuntu-26.04 + UV_USE_IO_URING: "0" + +execute: | + NAME="expressjs-${SCENARIO//./-}" + ROCK_FILE="${NAME}_0.1_amd64.rock" + IMAGE="${NAME}:0.1" + APP=$(sed -n 's/.*"name": *"\([^"]*\)".*/\1/p' "package.json") + + ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=True + export ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS + run_rockcraft init --name "${NAME}" --profile expressjs-framework + sed -i "s/^base: .*/base: ${SCENARIO//-/@}/g" rockcraft.yaml + if [ "${SCENARIO}" != "bare" ]; then + sed -i "s/^build-base: .*/build-base: ${SCENARIO//-/@}/g" rockcraft.yaml + else + # Install findutils for the bare scenario + # This is a workaround for the fact that the bare scenario does not have + # findutils installed by default, which is required for the test + cat >> rockcraft.yaml <> rockcraft.yaml + fi + # test the expressjs service with a Node version specified + node_version=20.18.2 + cat <> rockcraft.yaml + expressjs-framework/install-app: + npm-include-node: true + npm-node-version: $node_version + EOF + run_test + + container_id=$(docker ps -q -f name="${NAME}-container") + container_node_version=$(sudo docker exec $container_id node --version) + [ "$container_node_version" = "v$node_version" ] + +restore: | + NAME="expressjs-${SCENARIO//./-}" + docker stop "${NAME}-container" || true + docker rm --force "${NAME}-container" + rm -f "*.rock" rockcraft.yaml + docker system prune -a -f diff --git a/tests/spread/rockcraft/extension-expressjs-build/tsconfig.json b/tests/spread/rockcraft/extension-expressjs-build/tsconfig.json new file mode 100644 index 000000000..fc808ff82 --- /dev/null +++ b/tests/spread/rockcraft/extension-expressjs-build/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "nodenext", + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": false, + "noEmit": false, + "strict": true, + "skipLibCheck": true, + "sourceMap": true, + "outDir": "dist", + "paths": { "*": ["./*"] } + }, + "outDir": "dest/", + "include": ["src/**/*"], + "exclude": ["src/public"] +} diff --git a/tests/unit/extensions/test_expressjs.py b/tests/unit/extensions/test_expressjs.py index fd8e747e6..0d2db5409 100644 --- a/tests/unit/extensions/test_expressjs.py +++ b/tests/unit/extensions/test_expressjs.py @@ -38,18 +38,34 @@ def expressjs_extension(mock_extensions): @pytest.fixture def app_path(tmp_path): - app_path = tmp_path / "app" + return _create_app_path(tmp_path, "app") + + +@pytest.fixture +def package_json_file(app_path) -> None: + _create_package_json_file(app_path) + + +@pytest.fixture +def package_json_file_with_build(app_path) -> None: + _create_package_json_file(app_path, with_build=True) + + +def _create_app_path(path, name): + app_path = path / name app_path.mkdir(parents=True, exist_ok=True) return app_path -@pytest.fixture -def package_json_file(app_path): +def _create_package_json_file(app_path, *, with_build=False): + build_script = "" + if with_build: + build_script = ',"build": "tsc"' (app_path / "package.json").write_text( f"""{{ "name": "{_expressjs_project_name}", "scripts": {{ - "start": "node ./bin/www" + "start": "node ./bin/www"{build_script} }} }}""" ) @@ -345,6 +361,194 @@ def test_expressjs_extension_default( assert applied == expected_yaml_dict +@pytest.mark.parametrize( + ("base", "npm_include_node", "node_version", "expected_yaml_dict"), + [ + pytest.param( + "ubuntu@26.04", + False, + None, + { + "base": "ubuntu@26.04", + "build-base": "ubuntu@26.04", + "name": "foo-bar", + "platforms": { + "amd64": {}, + }, + "run-user": "_daemon_", + "parts": { + "expressjs-framework/install-app": { + "plugin": "npm", + "source": "app/", + "npm-include-node": False, + "npm-node-version": None, + "override-build": "rm -rf node_modules\n" + "npm install --include=dev\n" + "npm run build\n" + "craftctl default\n" + f"mkdir -p ${{CRAFT_PART_INSTALL}}/lib/node_modules/{_expressjs_project_name}/dist\n" + f"cp -r dist/. ${{CRAFT_PART_INSTALL}}/lib/node_modules/{_expressjs_project_name}/dist\n" + "npm config set script-shell=bash --location project\n" + "cp ${CRAFT_PART_BUILD}/.npmrc ${CRAFT_PART_INSTALL}/lib/node_modules/" + f"{_expressjs_project_name}/.npmrc\n" + f"chown -R 584792:584792 ${{CRAFT_PART_INSTALL}}/lib/node_modules/{_expressjs_project_name}\n" + f"ln -s /lib/node_modules/{_expressjs_project_name} " + "${CRAFT_PART_INSTALL}/app\n" + "chown -R 584792:584792 ${CRAFT_PART_INSTALL}/app\n", + "build-packages": ["nodejs", "npm"], + "stage-packages": ["ca-certificates_data", "nodejs_bins"], + "build-environment": [{"UV_USE_IO_URING": "0"}], + }, + "expressjs-framework/runtime": { + "plugin": "nil", + "stage": ["-etc/ssl/certs/ca-certificates.crt"], + "stage-packages": ["npm"], + }, + "expressjs-framework/logging": { + "plugin": "nil", + "override-build": ( + "craftctl default\n" + "mkdir -p $CRAFT_PART_INSTALL/opt/promtail\n" + "mkdir -p $CRAFT_PART_INSTALL/etc/promtail" + ), + "permissions": [ + {"path": "opt/promtail", "owner": 584792, "group": 584792}, + {"path": "etc/promtail", "owner": 584792, "group": 584792}, + ], + }, + }, + "services": { + "expressjs": { + "override": "replace", + "startup": "enabled", + "user": "_daemon_", + "working-dir": "/app", + "command": "npm start", + "environment": {"NODE_ENV": "production"}, + }, + }, + }, + id="ubuntu@26.04", + ), + pytest.param( + "bare", + False, + None, + { + "base": "bare", + "build-base": "ubuntu@26.04", + "name": "foo-bar", + "parts": { + "expressjs-framework/install-app": { + "build-packages": [ + "nodejs", + "npm", + ], + "npm-include-node": False, + "npm-node-version": None, + "override-build": "rm -rf node_modules\n" + "npm install --include=dev\n" + "npm run build\n" + "craftctl default\n" + "mkdir -p ${CRAFT_PART_INSTALL}/lib/node_modules/test-expressjs-project/dist\n" + "cp -r dist/. ${CRAFT_PART_INSTALL}/lib/node_modules/test-expressjs-project/dist\n" + "npm config set script-shell=bash --location project\n" + "cp ${CRAFT_PART_BUILD}/.npmrc " + "${CRAFT_PART_INSTALL}/lib/node_modules/test-expressjs-project/.npmrc\n" + "chown -R 584792:584792 ${CRAFT_PART_INSTALL}/lib/node_modules/test-expressjs-project\n" + "ln -s /lib/node_modules/test-expressjs-project " + "${CRAFT_PART_INSTALL}/app\n" + "chown -R 584792:584792 ${CRAFT_PART_INSTALL}/app\n" + "ln -sf /usr/bin/bash ${CRAFT_PART_INSTALL}/usr/bin/sh", + "plugin": "npm", + "source": "app/", + "stage-packages": [ + "bash_bins", + "ca-certificates_data", + "coreutils_bins", + ], + "build-environment": [{"UV_USE_IO_URING": "0"}], + }, + "expressjs-framework/runtime": { + "plugin": "nil", + "stage": ["-etc/ssl/certs/ca-certificates.crt"], + "stage-packages": [ + "libstdc++6", + "zlib1g", + "npm", + ], + }, + "expressjs-framework/logging": { + "plugin": "nil", + "override-build": ( + "craftctl default\n" + "mkdir -p $CRAFT_PART_INSTALL/opt/promtail\n" + "mkdir -p $CRAFT_PART_INSTALL/etc/promtail" + ), + "permissions": [ + {"path": "opt/promtail", "owner": 584792, "group": 584792}, + {"path": "etc/promtail", "owner": 584792, "group": 584792}, + ], + }, + }, + "platforms": { + "amd64": {}, + }, + "run-user": "_daemon_", + "services": { + "expressjs": { + "command": "npm start", + "environment": { + "NODE_ENV": "production", + }, + "override": "replace", + "startup": "enabled", + "user": "_daemon_", + "working-dir": "/app", + }, + }, + }, + id="bare", + ), + ], +) +@pytest.mark.usefixtures("expressjs_extension", "package_json_file_with_build") +def test_expressjs_v2_with_build_script( + tmp_path, + monkeypatch, + expressjs_input_yaml, + base, + npm_include_node, + node_version, + expected_yaml_dict, +): + monkeypatch.setenv("ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS", "1") + + expressjs_input_yaml["base"] = base + expressjs_input_yaml["build-base"] = "ubuntu@26.04" + expressjs_input_yaml["parts"] = { + "expressjs-framework/install-app": { + "npm-include-node": npm_include_node, + "npm-node-version": node_version, + } + } + applied = extensions.apply_extensions(tmp_path, expressjs_input_yaml) + + assert applied == expected_yaml_dict + + +@pytest.mark.usefixtures("expressjs_extension") +def test_expressjs_v2_top_level_package_json( + tmp_path, monkeypatch, expressjs_input_yaml +): + monkeypatch.setenv("ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS", "1") + expressjs_input_yaml["base"] = "ubuntu@26.04" + expressjs_input_yaml["build-base"] = "ubuntu@26.04" + _create_package_json_file(tmp_path) + applied = extensions.apply_extensions(tmp_path, expressjs_input_yaml) + assert applied["parts"]["expressjs-framework/install-app"]["source"] == "./" + + @pytest.mark.usefixtures("expressjs_extension") def test_expressjs_no_package_json_error(tmp_path, expressjs_input_yaml): with pytest.raises(ExtensionError) as exc: @@ -356,10 +560,78 @@ def test_expressjs_no_package_json_error(tmp_path, expressjs_input_yaml): ) +@pytest.mark.usefixtures("expressjs_extension") +def test_expressjs_v2_no_package_json_error( + tmp_path, monkeypatch, expressjs_input_yaml +): + monkeypatch.setenv("ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS", "1") + expressjs_input_yaml["base"] = "ubuntu@26.04" + expressjs_input_yaml["build-base"] = "ubuntu@26.04" + with pytest.raises(ExtensionError) as exc: + extensions.apply_extensions(tmp_path, expressjs_input_yaml) + assert str(exc.value) == "missing package.json file" + assert ( + str(exc.value.doc_slug) + == "/reference/extensions/express-framework/#project-requirements" + ) + + +@pytest.mark.usefixtures("expressjs_extension") +def test_expressjs_v2_directory_package_json_error( + tmp_path, monkeypatch, expressjs_input_yaml +): + monkeypatch.setenv("ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS", "1") + expressjs_input_yaml["base"] = "ubuntu@26.04" + expressjs_input_yaml["build-base"] = "ubuntu@26.04" + (tmp_path / "package.json").mkdir(parents=True, exist_ok=True) + with pytest.raises(ExtensionError) as exc: + extensions.apply_extensions(tmp_path, expressjs_input_yaml) + assert str(exc.value) == "missing package.json file" + assert ( + str(exc.value.doc_slug) + == "/reference/extensions/express-framework/#project-requirements" + ) + + +@pytest.mark.usefixtures("expressjs_extension") +def test_expressjs_v2_hidden_package_json_error( + tmp_path, monkeypatch, expressjs_input_yaml +): + monkeypatch.setenv("ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS", "1") + expressjs_input_yaml["base"] = "ubuntu@26.04" + expressjs_input_yaml["build-base"] = "ubuntu@26.04" + _create_package_json_file(_create_app_path(tmp_path, ".hidden")) + with pytest.raises(ExtensionError) as exc: + extensions.apply_extensions(tmp_path, expressjs_input_yaml) + assert str(exc.value) == "missing package.json file" + assert ( + str(exc.value.doc_slug) + == "/reference/extensions/express-framework/#project-requirements" + ) + + +@pytest.mark.usefixtures("expressjs_extension") +def test_expressjs_v2_multiple_package_json_error( + tmp_path, monkeypatch, expressjs_input_yaml +): + monkeypatch.setenv("ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS", "1") + expressjs_input_yaml["base"] = "ubuntu@26.04" + expressjs_input_yaml["build-base"] = "ubuntu@26.04" + _create_package_json_file(_create_app_path(tmp_path, "one")) + _create_package_json_file(_create_app_path(tmp_path, "two")) + with pytest.raises(ExtensionError) as exc: + extensions.apply_extensions(tmp_path, expressjs_input_yaml) + assert str(exc.value) == "multiple package.json files" + assert ( + str(exc.value.doc_slug) + == "/reference/extensions/express-framework/#project-requirements" + ) + + @pytest.mark.parametrize( ("package_json_path", "package_json_contents", "error_message"), [ - ("invalid-path", "", "missing package.json file in 'app' directory"), + ("invalid-path", "", "missing package.json file"), ("package.json", "[]", "invalid package.json file"), ( "package.json", @@ -380,14 +652,18 @@ def test_expressjs_no_package_json_error(tmp_path, expressjs_input_yaml): ], ) @pytest.mark.usefixtures("expressjs_extension") -def test_expressjs_invalid_package_json_scripts_error( +def test_expressjs_v2_invalid_package_json_scripts_error( tmp_path, + monkeypatch, app_path, expressjs_input_yaml, package_json_path, package_json_contents, error_message, ): + monkeypatch.setenv("ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS", "1") + expressjs_input_yaml["base"] = "ubuntu@26.04" + expressjs_input_yaml["build-base"] = "ubuntu@26.04" (app_path / package_json_path).write_text(package_json_contents) with pytest.raises(ExtensionError) as exc: extensions.apply_extensions(tmp_path, expressjs_input_yaml)