mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-03 02:26:43 +09:00
The generated Node Dockerfile installs the full dependency set when the plan has a build command, because vite/tsc/webpack are devDependencies. That is correct, but it also runs the postinstall of any browser driver in the tree: puppeteer alone downloads ~500MB of Chrome into the build layer. On a 25GB host that is the difference between a build that exports and one that dies with "no space left on device" while unpacking the image -- the observed failure fetched .cache/puppeteer/chrome/linux-145/... for a static vite site whose runtime is `serve -s dist`. Export the three standard opt-outs before `npm install`. The packages still install (nothing else changes); only their browser binaries are skipped, and no production build drives a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
342 lines
13 KiB
Python
342 lines
13 KiB
Python
"""Unit tests for the transparent build-pack layer (BuildpackService).
|
|
|
|
Pure / no network / no Flask app needed — detection runs against temp dirs full
|
|
of marker files, and generation is pure string production.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from app.services.buildpack_service import BuildpackService
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# detect()
|
|
# --------------------------------------------------------------------------- #
|
|
def test_detect_node_express(tmp_path):
|
|
(tmp_path / 'package.json').write_text(json.dumps({
|
|
'name': 'demo',
|
|
'dependencies': {'express': '^4.18.0'},
|
|
'scripts': {'start': 'node server.js'},
|
|
'engines': {'node': '>=20'},
|
|
}), encoding='utf-8')
|
|
|
|
plan = BuildpackService.detect(str(tmp_path))
|
|
|
|
assert plan['builder'] == 'nixpacks'
|
|
assert plan['language'] == 'node'
|
|
assert plan['framework'] == 'express'
|
|
assert plan['versions']['node'] == '20'
|
|
assert plan['start_command'] == 'node server.js'
|
|
assert plan['port'] == 3000
|
|
assert plan['confidence'] > 0
|
|
|
|
|
|
def test_detect_node_nextjs_build_command(tmp_path):
|
|
(tmp_path / 'package.json').write_text(json.dumps({
|
|
'dependencies': {'next': '14.0.0', 'react': '18'},
|
|
'scripts': {'build': 'next build', 'start': 'next start'},
|
|
}), encoding='utf-8')
|
|
(tmp_path / 'next.config.js').write_text('module.exports = {}', encoding='utf-8')
|
|
|
|
plan = BuildpackService.detect(str(tmp_path))
|
|
|
|
assert plan['framework'] == 'nextjs'
|
|
assert plan['build_command'] == 'npm run build'
|
|
|
|
|
|
def test_detect_python_flask(tmp_path):
|
|
(tmp_path / 'requirements.txt').write_text('Flask==3.0\ngunicorn\n', encoding='utf-8')
|
|
(tmp_path / 'app.py').write_text('app = None\n', encoding='utf-8')
|
|
|
|
plan = BuildpackService.detect(str(tmp_path))
|
|
|
|
assert plan['builder'] == 'nixpacks'
|
|
assert plan['language'] == 'python'
|
|
assert plan['framework'] == 'flask'
|
|
assert 'gunicorn' in plan['start_command']
|
|
assert plan['build_command'].startswith('pip install')
|
|
|
|
|
|
def test_detect_python_installs_invented_server(tmp_path):
|
|
"""When the buildpack invents a gunicorn/uvicorn start command, the build
|
|
must install that server -- a repo that never asked for it won't declare
|
|
it, and the container would die at boot with `gunicorn: not found`."""
|
|
(tmp_path / 'requirements.txt').write_text('Flask==3.0\n', encoding='utf-8')
|
|
(tmp_path / 'app.py').write_text('app = None\n', encoding='utf-8')
|
|
|
|
plan = BuildpackService.detect(str(tmp_path))
|
|
|
|
assert plan['start_command'].startswith('gunicorn')
|
|
assert 'pip install --no-cache-dir gunicorn' in plan['build_command']
|
|
|
|
|
|
def test_detect_python_version_from_pyproject(tmp_path):
|
|
(tmp_path / 'pyproject.toml').write_text(
|
|
'[project]\nname="x"\nrequires-python = ">=3.12"\n', encoding='utf-8')
|
|
|
|
plan = BuildpackService.detect(str(tmp_path))
|
|
|
|
assert plan['language'] == 'python'
|
|
assert plan['versions']['python'] == '3.12'
|
|
|
|
|
|
def test_detect_go(tmp_path):
|
|
(tmp_path / 'go.mod').write_text('module example.com/app\n\ngo 1.21\n', encoding='utf-8')
|
|
|
|
plan = BuildpackService.detect(str(tmp_path))
|
|
|
|
assert plan['builder'] == 'nixpacks'
|
|
assert plan['language'] == 'go'
|
|
assert plan['versions']['go'] == '1.21'
|
|
assert plan['build_command'] == 'go build -o /app/server ./...'
|
|
|
|
|
|
def test_detect_static(tmp_path):
|
|
(tmp_path / 'index.html').write_text('<html></html>', encoding='utf-8')
|
|
|
|
plan = BuildpackService.detect(str(tmp_path))
|
|
|
|
assert plan['builder'] == 'static'
|
|
assert plan['language'] == 'static'
|
|
assert plan['port'] == 80
|
|
|
|
|
|
def test_detect_dockerfile_present_wins(tmp_path):
|
|
# Dockerfile present alongside a package.json — defer to the author's build.
|
|
(tmp_path / 'Dockerfile').write_text('FROM node:20\n', encoding='utf-8')
|
|
(tmp_path / 'package.json').write_text('{}', encoding='utf-8')
|
|
|
|
plan = BuildpackService.detect(str(tmp_path))
|
|
|
|
assert plan['builder'] == 'dockerfile-present'
|
|
assert plan['confidence'] == 1.0
|
|
assert plan['language'] == 'node' # still labeled for display
|
|
|
|
|
|
def test_detect_unknown(tmp_path):
|
|
(tmp_path / 'README.md').write_text('# nothing here', encoding='utf-8')
|
|
|
|
plan = BuildpackService.detect(str(tmp_path))
|
|
|
|
assert plan['builder'] == 'unknown'
|
|
assert plan['confidence'] == 0.0
|
|
assert plan['notes']
|
|
|
|
|
|
def test_detect_missing_path():
|
|
plan = BuildpackService.detect('/path/that/does/not/exist/xyz')
|
|
assert plan['builder'] == 'unknown'
|
|
assert plan['notes']
|
|
|
|
|
|
def test_procfile_overrides_start_command(tmp_path):
|
|
(tmp_path / 'requirements.txt').write_text('flask\n', encoding='utf-8')
|
|
(tmp_path / 'Procfile').write_text('web: gunicorn wsgi:application\n', encoding='utf-8')
|
|
|
|
plan = BuildpackService.detect(str(tmp_path))
|
|
|
|
assert plan['start_command'] == 'gunicorn wsgi:application'
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# generate_dockerfile()
|
|
# --------------------------------------------------------------------------- #
|
|
def test_generate_dockerfile_node():
|
|
plan = {
|
|
'builder': 'nixpacks', 'language': 'node', 'framework': 'express',
|
|
'versions': {'node': '20'}, 'build_command': 'npm run build',
|
|
'start_command': 'node server.js', 'port': 3000,
|
|
}
|
|
df = BuildpackService.generate_dockerfile(plan)
|
|
assert 'FROM node:20-slim' in df
|
|
assert 'npm run build' in df
|
|
assert 'EXPOSE 3000' in df
|
|
assert 'server.js' in df
|
|
|
|
|
|
def test_generate_dockerfile_node_build_keeps_dev_dependencies():
|
|
"""A build step needs devDependencies (vite, tsc, ...).
|
|
|
|
`npm install --omit=dev` exits 0, so an `|| npm install` fallback never
|
|
fires and `npm run build` dies with `vite: not found`.
|
|
"""
|
|
plan = {
|
|
'builder': 'nixpacks', 'language': 'node', 'framework': 'vite',
|
|
'versions': {'node': '24'}, 'build_command': 'npm run build',
|
|
'start_command': 'vite preview --host --port 4173', 'port': 4173,
|
|
}
|
|
df = BuildpackService.generate_dockerfile(plan)
|
|
assert '--omit=dev' not in df
|
|
assert 'RUN npm install\n' in df
|
|
# Locally installed CLIs are only reachable via node_modules/.bin.
|
|
assert 'ENV PATH=/app/node_modules/.bin:$PATH' in df
|
|
assert df.index('RUN npm install\n') < df.index('RUN npm run build')
|
|
|
|
|
|
def test_generate_dockerfile_node_skips_browser_downloads():
|
|
"""devDependencies stay, but their browser binaries must not.
|
|
|
|
Keeping devDependencies (see above) also drags in puppeteer/playwright/
|
|
cypress postinstalls, which fetch a full Chrome (~500MB) into the build
|
|
layer. Nothing in a production build drives a browser, and on a small
|
|
host that download is what exhausts the disk during image export.
|
|
"""
|
|
plan = {
|
|
'builder': 'nixpacks', 'language': 'node', 'framework': 'vite',
|
|
'versions': {'node': '24'}, 'build_command': 'npm run build',
|
|
'start_command': 'serve -s dist -l tcp://0.0.0.0:4173', 'port': 4173,
|
|
}
|
|
df = BuildpackService.generate_dockerfile(plan)
|
|
assert 'PUPPETEER_SKIP_DOWNLOAD=1' in df
|
|
assert 'PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1' in df
|
|
assert 'CYPRESS_INSTALL_BINARY=0' in df
|
|
# The opt-out only works if it is exported before the install runs.
|
|
assert df.index('PUPPETEER_SKIP_DOWNLOAD=1') < df.index('RUN npm install')
|
|
|
|
|
|
def test_detect_vite_defaults_to_serve_not_preview(tmp_path):
|
|
"""`vite preview` host-checks against preview.allowedHosts and blocks
|
|
every domain the panel proxy forwards -- the default start command must
|
|
be a host-agnostic static server, and the generator must preinstall it
|
|
(the repo never declares serve)."""
|
|
(tmp_path / 'package.json').write_text(json.dumps({
|
|
'name': 'v', 'scripts': {'build': 'vite build'},
|
|
'devDependencies': {'vite': '^6.0.0'},
|
|
}), encoding='utf-8')
|
|
|
|
plan = BuildpackService.detect(str(tmp_path))
|
|
assert plan['framework'] == 'vite'
|
|
assert plan['start_command'].startswith('serve ')
|
|
assert 'preview' not in plan['start_command']
|
|
|
|
df = BuildpackService.generate_dockerfile(plan)
|
|
assert 'RUN npm install -g serve' in df
|
|
|
|
|
|
def test_detect_vite_repo_start_script_wins(tmp_path):
|
|
"""A repo-authored start script is the author's contract -- keep it,
|
|
and don't preinstall serve for a command that doesn't use it."""
|
|
(tmp_path / 'package.json').write_text(json.dumps({
|
|
'name': 'v', 'scripts': {'build': 'vite build',
|
|
'start': 'node server.mjs'},
|
|
'devDependencies': {'vite': '^6.0.0'},
|
|
}), encoding='utf-8')
|
|
|
|
plan = BuildpackService.detect(str(tmp_path))
|
|
assert plan['start_command'] == 'node server.mjs'
|
|
assert 'RUN npm install -g serve' not in \
|
|
BuildpackService.generate_dockerfile(plan)
|
|
|
|
|
|
def test_generate_dockerfile_node_without_build_omits_dev():
|
|
plan = {
|
|
'builder': 'nixpacks', 'language': 'node', 'framework': 'express',
|
|
'versions': {'node': '20'}, 'start_command': 'node server.js',
|
|
'port': 3000,
|
|
}
|
|
df = BuildpackService.generate_dockerfile(plan)
|
|
assert 'RUN npm install --omit=dev || npm install' in df
|
|
|
|
|
|
def test_generate_dockerfile_python():
|
|
plan = {
|
|
'builder': 'nixpacks', 'language': 'python', 'framework': 'flask',
|
|
'versions': {'python': '3.11'},
|
|
'build_command': 'pip install --no-cache-dir -r requirements.txt',
|
|
'start_command': 'gunicorn app:app', 'port': 8000,
|
|
}
|
|
df = BuildpackService.generate_dockerfile(plan)
|
|
assert 'FROM python:3.11-slim' in df
|
|
assert 'pip install' in df
|
|
assert 'EXPOSE 8000' in df
|
|
assert 'gunicorn app:app' in df
|
|
|
|
|
|
def test_generate_dockerfile_go_multistage():
|
|
plan = {
|
|
'builder': 'nixpacks', 'language': 'go', 'versions': {'go': '1.22'},
|
|
'build_command': 'go build -o /app/server ./...', 'port': 8080,
|
|
'start_command': '/app/server',
|
|
}
|
|
df = BuildpackService.generate_dockerfile(plan)
|
|
assert 'FROM golang:1.22 AS build' in df
|
|
assert 'distroless' in df
|
|
assert 'EXPOSE 8080' in df
|
|
|
|
|
|
def test_generate_dockerfile_static_nginx():
|
|
plan = {'builder': 'static', 'language': 'static', 'port': 80, 'versions': {}}
|
|
df = BuildpackService.generate_dockerfile(plan)
|
|
assert 'FROM nginx:alpine' in df
|
|
assert '/usr/share/nginx/html' in df
|
|
|
|
|
|
def test_generate_dockerfile_present_is_noop():
|
|
plan = {'builder': 'dockerfile-present', 'language': 'node'}
|
|
df = BuildpackService.generate_dockerfile(plan)
|
|
assert 'already provides a Dockerfile' in df
|
|
|
|
|
|
def test_generate_dockerfile_unknown():
|
|
plan = {'builder': 'unknown'}
|
|
df = BuildpackService.generate_dockerfile(plan)
|
|
assert 'Unable to generate' in df
|
|
|
|
|
|
def test_generate_compose():
|
|
plan = {'builder': 'nixpacks', 'language': 'node', 'port': 3000, 'versions': {}}
|
|
compose = BuildpackService.generate_compose(plan, 'My App!')
|
|
assert 'services:' in compose
|
|
assert 'my-app' in compose
|
|
assert '"3000:3000"' in compose
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# apply_overrides()
|
|
# --------------------------------------------------------------------------- #
|
|
def test_apply_overrides_merges():
|
|
plan = {
|
|
'builder': 'nixpacks', 'language': 'node', 'versions': {'node': '18'},
|
|
'build_command': 'npm run build', 'start_command': 'node index.js',
|
|
'port': 3000, 'notes': [],
|
|
}
|
|
merged = BuildpackService.apply_overrides(plan, {
|
|
'node_version': '20',
|
|
'build_command': 'npm run compile',
|
|
'start_command': 'node dist/main.js',
|
|
'port': 8080,
|
|
})
|
|
assert merged['versions']['node'] == '20'
|
|
assert merged['build_command'] == 'npm run compile'
|
|
assert merged['start_command'] == 'node dist/main.js'
|
|
assert merged['port'] == 8080
|
|
# Original plan must be untouched (non-mutating).
|
|
assert plan['versions']['node'] == '18'
|
|
assert plan['port'] == 3000
|
|
|
|
|
|
def test_apply_overrides_none_is_safe():
|
|
plan = {'builder': 'nixpacks', 'language': 'go', 'versions': {'go': '1.22'}}
|
|
merged = BuildpackService.apply_overrides(plan, None)
|
|
assert merged['versions']['go'] == '1.22'
|
|
|
|
|
|
def test_apply_overrides_bad_port_ignored():
|
|
plan = {'builder': 'nixpacks', 'port': 3000, 'versions': {}}
|
|
merged = BuildpackService.apply_overrides(plan, {'port': 'not-a-number'})
|
|
assert merged['port'] == 3000
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# cache key
|
|
# --------------------------------------------------------------------------- #
|
|
def test_plan_cache_key_stable_and_distinct():
|
|
k1 = BuildpackService.plan_cache_key('https://github.com/a/b.git', 'abc')
|
|
k2 = BuildpackService.plan_cache_key('https://github.com/a/b.git', 'abc')
|
|
k3 = BuildpackService.plan_cache_key('https://github.com/a/b.git', 'def')
|
|
assert k1 == k2
|
|
assert k1 != k3
|
|
assert k1.startswith('buildpack:plan:')
|