diff --git a/CHANGELOG.md b/CHANGELOG.md index 73e4fb719..f23dc77aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to this project are documented in this file. The format is b ### Fixed +- A fresh app's `make dev` no longer prints Vite errors or warnings, and `npm audit` reports nothing. The `simple_module_settings` wheel no longer ships `ui/components/validation-errors.test.ts`: that test made the host's Vite dependency scan fail with "Failed to run dependency scan … vitest". It now lives in `modules/settings/tests-js/`, and `make ci-build-packages` fails if any wheel contains a JS or TS `*.test.*` or `*.spec.*` file, a `test-dom.tsx` or a `page-tests/` directory. The scaffold's `client_app/vite.config.ts` loads under Vite 8's native config loader: it uses `import.meta.dirname` and `.ts` import extensions, and the template `tsconfig.json` sets `allowImportingTsExtensions`. It also uses `optimizeDeps.rolldownOptions` instead of the deprecated `esbuildOptions`. New apps' root `package.json` overrides `postcss-selector-parser` to `^6.1.5`, which is what `@tailwindcss/typography` pulls in, as this repo does. An existing app can copy `host/client_app/vite.config.ts` and `tsconfig.json` from a new app, and add the override (#900). - The `Browse`, `Create` and `Edit` pages that `smpy create-module` (and `make new-module`) generates now render inside the app shell: each sets `.layout = [AuthenticatedLayout]`. Before, they had no sidebar, header or footer, so the module's sidebar entry led to a bare page with no way back. To fix a module generated earlier, add `import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';` and that assignment to each of its pages, or use `AdminLayout` for pages you moved under `/admin` and `ADMIN_SIDEBAR` (#897). - A fresh `smpy new` app passes its own `make lint`. `host/alembic.ini` gains a `[post_write_hooks]` entry that runs the project's ruff (`check --select I --fix`, then `format`) on every new revision, so the initial-schema revision and each `make migration` output no longer fail `ruff format --check` and `ruff check` (this repo's `host/alembic.ini` has it too); a standalone `smpy create-host` project, which has no workspace root, gets a `dev` dependency group with `ruff` for the hook. `create-module` sorts the `module.py` constants import for any module name (it was unsorted for names that sort before `group`, such as `bookmarks`). The `# ty: ignore[invalid-assignment]` in `contracts/schemas.py` is generated only for a module inside this repo, where ty checks that rule, and new apps pin `ty>=0.0.90,<0.0.100`, since ty 0.0.100 renamed the `unresolved-attribute` rule the scaffold's config names. The hooks use Alembic's `module` runner, so `simple_module_db` and the host template now require `alembic>=1.16.3`. An existing app can copy the `[post_write_hooks]` block from a new app's `host/alembic.ini` (and raise its own `alembic` requirement to `>=1.16.3`), drop the unused ignore comments and pin ty the same way (#896). - `make doctor` (and `smpy doctor`) now runs the migration checks it documents. It reads `alembic_version` and the table list from `SM_DATABASE_URL` and the heads from the project's `alembic.ini`: `SM010` is an error when the database lacks any head and a warning when it was never migrated or could not be read, and `SM011` reports a module table that a database at every head does not have. A development boot reports `SM011` too once its database is at head (or once the setup wizard has migrated it), and the Doctor screen keeps it on re-run. Doctor now resolves a relative SQLite `SM_DATABASE_URL` against the project root as the app does, so run from `host/` it reads the app's database for `SM033` too. Before, neither code was ever reported. **Behaviour change:** `make doctor` now exits 1 against a dev database that is behind head; run `make migrate` (#895). diff --git a/Makefile b/Makefile index de0f7d6e0..66774c439 100644 --- a/Makefile +++ b/Makefile @@ -252,6 +252,7 @@ ci-check-untranslated: ci-build-packages: rm -rf dist-py-ci uv build --all-packages --out-dir dist-py-ci + uv run python scripts/check_wheel_contents.py dist-py-ci rm -rf dist-py-ci # Diagnostics diff --git a/docs/module-layout.md b/docs/module-layout.md index 848edc963..58eb845d1 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -93,7 +93,9 @@ source prefixes for Vite's bare-import fallback and dependency scan. Existing hosts must update that reader when adopting UI sources; upgrading Python packages regenerates manifests but does not rewrite host-owned Vite config. Wheel templates include UI sources and exclude `**/*.test.*`, `**/*.spec.*`, -`**/test-dom.tsx` and `**/page-tests/**` so old frontend test helpers do not ship. +`**/test-dom.tsx` and `**/page-tests/**` so old frontend test helpers do not +ship. `make ci-build-packages` fails when any built wheel (this repo's own modules included) still contains a JS or TS test file, +`test-dom.tsx` or a `page-tests/` directory (`scripts/check_wheel_contents.py`). Keep CSS tokens in root `theme.css` and component rules in root `styles.css`. The separate legacy `smpy module build` command still uses root `assets_src/` diff --git a/docs/superpowers/plans/2026-11-11-scaffold-vite8-clean.md b/docs/superpowers/plans/2026-11-11-scaffold-vite8-clean.md new file mode 100644 index 000000000..7237d4466 --- /dev/null +++ b/docs/superpowers/plans/2026-11-11-scaffold-vite8-clean.md @@ -0,0 +1,11 @@ +# Plan: a fresh app's `make dev` runs clean (#900) + +Design: `docs/superpowers/specs/2026-11-11-scaffold-vite8-clean-design.md`. + +1. Move `validation-errors.test.ts` to `modules/settings/tests-js/`. +2. Add `scripts/check_wheel_contents.py` and its tests, and wire it into + `make ci-build-packages`. +3. Port the template's `vite.config.ts` and `tsconfig.json` to Vite 8, and update the #152 + test. +4. Add `_APP_NPM_OVERRIDES` to the root `package.json` in both layouts, with tests. +5. Add a CHANGELOG entry, then run the real checks in a scaffolded app. diff --git a/docs/superpowers/specs/2026-11-11-scaffold-vite8-clean-design.md b/docs/superpowers/specs/2026-11-11-scaffold-vite8-clean-design.md new file mode 100644 index 000000000..bd299b3cd --- /dev/null +++ b/docs/superpowers/specs/2026-11-11-scaffold-vite8-clean-design.md @@ -0,0 +1,34 @@ +# A fresh app's `make dev` runs clean (#900) + +## Goal and scope + +A new `smpy new` app should print no Vite errors or warnings on `make dev`, and should +have no `npm audit` findings. Each problem below was reproduced on `main` in a scaffolded +app running Vite 8.3.7. + +| Symptom | Cause | Fix | +|---|---|---| +| "Failed to run dependency scan … vitest" | the `simple_module_settings` wheel ships `ui/components/validation-errors.test.ts`; Vite's dep scan walks installed module packages | move the test to `modules/settings/tests-js/`, where Vitest's `include` actually collects it (it was never run before); `make ci-build-packages` runs `scripts/check_wheel_contents.py`, which fails on any JS or TS `*.test.*` or `*.spec.*` file (by basename, so `openapi.spec.json` passes), a `test-dom.tsx`, or a member under a `page-tests/` directory | +| native config loader warning: `__dirname`, extensionless `./module-assets` and `./vite.dev-url` | the template predates Vite 8 | use `import.meta.dirname` and `.ts` extensions, and set `allowImportingTsExtensions: true` in the template tsconfig; this is legal because the tsconfig has `noEmit`, and this repo's host does the same | +| `optimizeDeps.esbuildOptions` is deprecated | Vite 8 scans with Rolldown | `optimizeDeps.rolldownOptions.resolve.modules`, which this repo's host already uses for the #152 fallback | +| 3 moderate `npm audit` findings | `@tailwindcss/typography@0.5.20`, the latest release, pins `postcss-selector-parser@6.0.10` | the root `package.json` of new apps, in both layouts, overrides it to `^6.1.5`, as this repo's root does | + +## Decisions made + +- The wheel check runs on built wheels, in the `ci-build-packages` CI job, rather than as a + source-tree rule. It checks what actually ships, including future hatch config mistakes. +- The override value comes from one constant, `_APP_NPM_OVERRIDES`. A test keeps it equal + to this repo's own override. +- Existing apps are left alone; the CHANGELOG says what to copy. + +## Testing + +- `scripts/tests/test_check_wheel_contents.py`. +- `framework/cli/tests/test_cli_scaffold_vite8.py` covers the template config, the + overrides in both layouts, and their parity with this repo's override. +- The existing #152 test now asserts `rolldownOptions`. +- Real checks: + - `make ci-build-packages`: 23 clean wheels; `main`'s settings wheel fails the check; + - in a scaffolded app with the new template: Vite dev starts with no warnings (and no + scan error once the test file is gone), `vite build` and `tsc` pass, and a fresh + `npm install` gives `npm audit`: 0 vulnerabilities. diff --git a/framework/cli/simple_module_cli/_app_project_manifests.py b/framework/cli/simple_module_cli/_app_project_manifests.py index 1fc134700..d4248b2b6 100644 --- a/framework/cli/simple_module_cli/_app_project_manifests.py +++ b/framework/cli/simple_module_cli/_app_project_manifests.py @@ -51,6 +51,11 @@ "vite": "^8.2.2", } +# `@tailwindcss/typography` (pulled in by @simple-module-py/ui) pins +# postcss-selector-parser 6.0.10, which `npm audit` flags as a moderate ReDoS +# (GH #900). This repo's root package.json carries the same override. +_APP_NPM_OVERRIDES = {"postcss-selector-parser": "^6.1.5"} + def _db_url(db: str, db_name: str, *, flat: bool, password: str = "") -> str: if db == "postgres": diff --git a/framework/cli/simple_module_cli/app_project.py b/framework/cli/simple_module_cli/app_project.py index 1ac9a1de6..dc59fd729 100644 --- a/framework/cli/simple_module_cli/app_project.py +++ b/framework/cli/simple_module_cli/app_project.py @@ -22,6 +22,7 @@ from simple_module_cli._app_project_manifests import ( _APP_NPM_DEPS, _APP_NPM_DEV_DEPS, + _APP_NPM_OVERRIDES, _APP_PY_DEV_DEPS, _FRAMEWORK_VERSION, _db_url, @@ -160,19 +161,21 @@ def create_app_project( ) host_pyproject.write_text(text, encoding="utf-8") + pkg_path = target / "package.json" + pkg_data: dict[str, Any] = ( + _json.loads(pkg_path.read_text(encoding="utf-8")) + if pkg_path.exists() + else {"name": to_kebab_case(name), "private": True, "type": "module"} + ) if flat: # The workspace template already emits a top-level package.json # with workspaces; flat mode has none, so seed one with the # framework npm pins so `npm install` resolves at the root. - pkg_path = target / "package.json" - pkg_data: dict[str, Any] = ( - _json.loads(pkg_path.read_text(encoding="utf-8")) - if pkg_path.exists() - else {"name": to_kebab_case(name), "private": True, "type": "module"} - ) pkg_data.setdefault("dependencies", {}).update(_APP_NPM_DEPS) pkg_data.setdefault("devDependencies", {}).update(_APP_NPM_DEV_DEPS) - pkg_path.write_text(_json.dumps(pkg_data, indent=2) + "\n", encoding="utf-8") + # npm reads `overrides` only from the root package.json, in both layouts. + pkg_data.setdefault("overrides", {}).update(_APP_NPM_OVERRIDES) + pkg_path.write_text(_json.dumps(pkg_data, indent=2) + "\n", encoding="utf-8") ctx = ScaffoldCtx(name=name, db=db, tenancy=tenancy, selected=tuple(resolved)) for mod_name in resolved: diff --git a/framework/cli/simple_module_cli/templates/host/client_app/tsconfig.json b/framework/cli/simple_module_cli/templates/host/client_app/tsconfig.json index 4efdf6861..76f0fd2e1 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/tsconfig.json +++ b/framework/cli/simple_module_cli/templates/host/client_app/tsconfig.json @@ -10,7 +10,7 @@ "esModuleInterop": true, "skipLibCheck": true, "types": ["vite/client", "node"], - "allowImportingTsExtensions": false + "allowImportingTsExtensions": true }, "include": ["**/*.ts", "**/*.tsx"] } diff --git a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts index 783067f51..92317a47b 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts +++ b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts @@ -3,8 +3,8 @@ import path from 'node:path'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import { type Plugin, defineConfig } from 'vite'; -import { loadModuleAssets } from './module-assets'; -import { viteDevServer } from './vite.dev-url'; +import { loadModuleAssets } from './module-assets.ts'; +import { viteDevServer } from './vite.dev-url.ts'; // Force every importer (host, workspace module, wheel-installed module) // to resolve to one React copy + a single Inertia hook context. Without @@ -29,11 +29,11 @@ function findNodeModulesRoot(start: string): string { } return start; } -const fsRoot = findNodeModulesRoot(__dirname); +const fsRoot = findNodeModulesRoot(import.meta.dirname); // Walks up from client_app to the directory holding the project .env itself — // fsRoot tracks node_modules, which (in flat mode) is NOT where .env lives. -const { origin: devUrl, port: devPort } = viteDevServer(__dirname); +const { origin: devUrl, port: devPort } = viteDevServer(import.meta.dirname); const { fsAllow: moduleFsAllow, @@ -42,7 +42,7 @@ const { pagesPrefixes: modulePagesPrefixes, aliases: moduleAliases, npmNames: moduleNpmNames, -} = loadModuleAssets(__dirname); +} = loadModuleAssets(import.meta.dirname); const fakeWorkspaceImporter = path.join(fsRoot, 'package.json'); // CJS-only deps like `clsx`, `tailwind-merge`, `class-variance-authority` @@ -99,7 +99,7 @@ function collectOptimizeIncludes(): string[] { 'use-sync-external-store/shim/with-selector', ]); const visited = new Set(); - const queue: string[] = [path.join(__dirname, 'package.json'), ...modulePkgJsonPaths]; + const queue: string[] = [path.join(import.meta.dirname, 'package.json'), ...modulePkgJsonPaths]; while (queue.length > 0) { const pkgJsonPath = queue.shift(); if (!pkgJsonPath || visited.has(pkgJsonPath)) continue; @@ -177,7 +177,7 @@ function moduleBareImportResolver(): Plugin { export default defineConfig({ plugins: [moduleBareImportResolver(), react(), tailwindcss()], - root: __dirname, + root: import.meta.dirname, resolve: { // `#module/` -> that module's package directory. Optional sugar for // hand-written imports; modules.generated.css does not rely on it. @@ -187,22 +187,24 @@ export default defineConfig({ optimizeDeps: { entries: ['main.tsx', 'pages/**/*.tsx', ...moduleOptimizeEntries], include: collectOptimizeIncludes(), - // Vite's dep scanner runs esbuild against every entry above. When the + // Vite's dep scanner runs Rolldown against every entry above. When the // entry sits outside the workspace (a wheel-installed module's - // pages/, or sometimes even a workspace module's pages/), esbuild's - // upward node_modules walk from the importer does not always reach - // the hoisted node_modules at fsRoot. Seeding it as NODE_PATH-style - // fallback ensures bare specifiers like `maplibre-gl` resolve during - // scan-imports. See GitHub issue #152. - esbuildOptions: { - nodePaths: [path.join(fsRoot, 'node_modules')], + // pages/, or sometimes even a workspace module's pages/), the upward + // node_modules walk from the importer does not always reach the + // hoisted node_modules at fsRoot. Adding it to the resolve search is + // a NODE_PATH-style fallback, so bare specifiers like `maplibre-gl` + // resolve during scan-imports. See GitHub issue #152. + rolldownOptions: { + resolve: { + modules: ['node_modules', path.join(fsRoot, 'node_modules')], + }, }, }, build: { outDir: '../static/dist', manifest: true, rollupOptions: { - input: path.resolve(__dirname, 'main.tsx'), + input: path.resolve(import.meta.dirname, 'main.tsx'), }, }, server: { diff --git a/framework/cli/tests/test_cli_scaffold_vite8.py b/framework/cli/tests/test_cli_scaffold_vite8.py new file mode 100644 index 000000000..b051d80e8 --- /dev/null +++ b/framework/cli/tests/test_cli_scaffold_vite8.py @@ -0,0 +1,48 @@ +"""A fresh app's `make dev` runs without Vite 8 warnings or `npm audit` findings (GH #900).""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest +from simple_module_cli import app_project +from simple_module_cli._app_project_manifests import _APP_NPM_OVERRIDES +from simple_module_cli.app_project import create_app_project + +_CLIENT_APP = Path(app_project.__file__).parent / "templates" / "host" / "client_app" +_REPO_ROOT = Path(__file__).resolve().parents[3] + + +def test_vite_config_loads_under_the_native_config_loader() -> None: + """Vite 8 warns about `__dirname` and extensionless relative imports.""" + config = (_CLIENT_APP / "vite.config.ts").read_text(encoding="utf-8") + assert "__dirname" not in config + relative = re.findall(r"from '(\.[^']+)'", config) + assert relative, "expected the config's helper imports" + assert all(spec.endswith(".ts") for spec in relative), relative + tsconfig = (_CLIENT_APP / "tsconfig.json").read_text(encoding="utf-8") + assert '"allowImportingTsExtensions": true' in tsconfig + + +def test_vite_config_uses_rolldown_options() -> None: + """`optimizeDeps.esbuildOptions` is deprecated since Vite moved to Rolldown.""" + config = (_CLIENT_APP / "vite.config.ts").read_text(encoding="utf-8") + assert "esbuildOptions" not in config + assert "rolldownOptions" in config + + +@pytest.mark.parametrize("flat", [False, True]) +def test_root_package_json_overrides_the_audited_dependency(tmp_path: Path, flat: bool) -> None: + target = tmp_path / "app" + create_app_project(target, name="app", db="sqlite", tenancy=False, selected=[], flat=flat) + pkg = json.loads((target / "package.json").read_text(encoding="utf-8")) + assert pkg["overrides"] == _APP_NPM_OVERRIDES + + +def test_app_overrides_match_this_repos() -> None: + """The repo's own root package.json is what CI audits; keep the two in step.""" + repo_pkg = json.loads((_REPO_ROOT / "package.json").read_text(encoding="utf-8")) + for name, spec in _APP_NPM_OVERRIDES.items(): + assert repo_pkg["overrides"][name] == spec diff --git a/framework/cli/tests/test_scaffolding_host.py b/framework/cli/tests/test_scaffolding_host.py index c8e74d97c..da421e5b6 100644 --- a/framework/cli/tests/test_scaffolding_host.py +++ b/framework/cli/tests/test_scaffolding_host.py @@ -226,8 +226,8 @@ async def test_cli_create_host_runs_end_to_end(self, tmp_path): ) async def test_scaffold_vite_config_includes_node_paths_fallback(self, tmp_path): - """vite.config.ts must seed optimizeDeps.esbuildOptions.nodePaths - with the workspace node_modules so esbuild's scan-imports pass can + """vite.config.ts must add to optimizeDeps.rolldownOptions.resolve.modules + with the workspace node_modules so the dep scanner's scan-imports pass can resolve cross-package bare imports from module pages whose importers sit outside the host's client_app (e.g. wheel-installed modules). @@ -242,22 +242,21 @@ async def test_scaffold_vite_config_includes_node_paths_fallback(self, tmp_path) # The scanner fallback must be configured under optimizeDeps so it # applies to both dev pre-bundling and `vite build`'s pre-bundle pass. - assert "esbuildOptions" in vite_config, ( - "vite.config.ts must configure optimizeDeps.esbuildOptions" - ) - assert "nodePaths" in vite_config, ( - "vite.config.ts must seed optimizeDeps.esbuildOptions.nodePaths " - "with the workspace node_modules (GH issue #152)." + # Vite 8 scans with Rolldown; `esbuildOptions` is deprecated (GH #900). + assert "rolldownOptions" in vite_config, "must configure optimizeDeps.rolldownOptions" + assert "modules: ['node_modules', path.join(fsRoot, 'node_modules')]" in vite_config, ( + "vite.config.ts must add the workspace node_modules to " + "optimizeDeps.rolldownOptions.resolve.modules (GH issue #152)." ) # The seeded path must reference fsRoot — the dir that contains the # hoisted node_modules — not a hardcoded literal that breaks in # flat-vs-workspace layouts. assert "fsRoot" in vite_config, ( - "nodePaths must reference fsRoot (not a hardcoded literal) so the " + "the resolve fallback must reference fsRoot (not a hardcoded literal) so the " "fallback works in both flat and workspace layouts (GH issue #152)." ) assert "node_modules" in vite_config, ( - "nodePaths entry must include 'node_modules' (GH issue #152)." + "the resolve fallback must include 'node_modules' (GH issue #152)." ) async def test_scaffold_vite_resolver_does_not_skip_workspace_modules(self, tmp_path): diff --git a/modules/settings/settings/ui/components/validation-errors.test.ts b/modules/settings/tests-js/settings_validation-errors.test.ts similarity index 73% rename from modules/settings/settings/ui/components/validation-errors.test.ts rename to modules/settings/tests-js/settings_validation-errors.test.ts index 3e5004e39..87024bee8 100644 --- a/modules/settings/settings/ui/components/validation-errors.test.ts +++ b/modules/settings/tests-js/settings_validation-errors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { splitValidationErrors } from './validation-errors'; +import { splitValidationErrors } from '../settings/ui/components/validation-errors'; describe('splitValidationErrors', () => { it('maps located errors to their field', () => { @@ -14,8 +14,13 @@ describe('splitValidationErrors', () => { expect(r.form).toEqual(['timeout exceeds lease']); }); - it('tolerates a non-list detail', () => { - expect(splitValidationErrors('nope')).toEqual({ fields: {}, form: [] }); + it('surfaces a string detail at form level, so a 422 is never swallowed', () => { + expect(splitValidationErrors('nope')).toEqual({ fields: {}, form: ['nope'] }); + }); + + it('tolerates a detail that is neither a list nor a string', () => { + expect(splitValidationErrors(null)).toEqual({ fields: {}, form: [] }); + expect(splitValidationErrors({ msg: 'x' })).toEqual({ fields: {}, form: [] }); }); it('maps a nested loc to its field with the path in the message', () => { diff --git a/scripts/check_wheel_contents.py b/scripts/check_wheel_contents.py new file mode 100644 index 000000000..21045616a --- /dev/null +++ b/scripts/check_wheel_contents.py @@ -0,0 +1,57 @@ +"""Fail when a built wheel ships a JS test file (GH #900). + +A host's Vite dep scan walks every installed module's package directory, so a +``*.test.ts`` inside a wheel makes it import ``vitest``, which an app does not +install: "Failed to run dependency scan". JS tests belong in ``tests-js/``. + +Usage: ``python scripts/check_wheel_contents.py `` (run by +``make ci-build-packages`` on the wheels it builds). Exit 0 if clean, 1 otherwise. +""" + +from __future__ import annotations + +import argparse +import re +import sys +import zipfile +from pathlib import Path, PurePosixPath + +# What the module template's `[tool.hatch.build.targets.wheel] exclude` keeps out, +# narrowed to JS so a data file such as `openapi.spec.json` is not flagged. +_JS_TEST = re.compile(r"\.(test|spec)\.[cm]?[jt]sx?$") +_TEST_FILES = frozenset({"test-dom.tsx"}) +_TEST_DIRS = frozenset({"page-tests"}) + + +def forbidden_members(wheel: Path) -> list[str]: + with zipfile.ZipFile(wheel) as zf: + names = zf.namelist() + return [ + name + for name in names + if _JS_TEST.search(PurePosixPath(name).name) + or PurePosixPath(name).name in _TEST_FILES + or _TEST_DIRS.intersection(PurePosixPath(name).parts[:-1]) + ] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("dist", type=Path, help="directory holding the built wheels") + args = parser.parse_args(argv) + + wheels = sorted(args.dist.glob("*.whl")) + if not wheels: + print(f"no wheels found in {args.dist}", file=sys.stderr) + return 1 + errors = [f"{w.name}: {m}" for w in wheels for m in forbidden_members(w)] + for error in errors: + print(f"ERROR: JS test file in a published wheel: {error}", file=sys.stderr) + if errors: + return 1 + print(f"OK: {len(wheels)} wheels carry no JS test files.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_check_wheel_contents.py b/scripts/tests/test_check_wheel_contents.py new file mode 100644 index 000000000..70b758f93 --- /dev/null +++ b/scripts/tests/test_check_wheel_contents.py @@ -0,0 +1,55 @@ +"""Tests for scripts/check_wheel_contents.py.""" + +from __future__ import annotations + +import zipfile +from pathlib import Path + +import pytest + +from scripts.check_wheel_contents import main + + +def _wheel(dist: Path, name: str, members: list[str]) -> None: + dist.mkdir(exist_ok=True) + with zipfile.ZipFile(dist / name, "w") as zf: + for member in members: + zf.writestr(member, "") + + +@pytest.mark.parametrize( + "member", + [ + "settings/ui/components/validation-errors.test.ts", + "orders/pages/Browse.spec.tsx", + "orders/pages/test-dom.tsx", + "orders/pages/page-tests/helpers.ts", + "orders/pages/nested/page-tests/deep/x.ts", + "orders/ui/Thing.test.tsx", + "orders/ui/thing.spec.mjs", + ], +) +def test_js_test_file_in_a_wheel_fails(tmp_path: Path, member: str, capsys) -> None: + _wheel(tmp_path, "pkg-0.1-py3-none-any.whl", ["pkg/__init__.py", member]) + assert main([str(tmp_path)]) == 1 + assert member in capsys.readouterr().err + + +def test_clean_wheels_pass(tmp_path: Path) -> None: + _wheel( + tmp_path, + "pkg-0.1-py3-none-any.whl", + [ + "pkg/__init__.py", + "pkg/pages/Browse.tsx", + "pkg/testing.py", + "pkg/latest.ts", + "pkg/openapi.spec.json", + "pkg/fixtures.test.d/data.json", + ], + ) + assert main([str(tmp_path)]) == 0 + + +def test_an_empty_dist_fails(tmp_path: Path) -> None: + assert main([str(tmp_path)]) == 1