shipit-cli 0.19.3__tar.gz → 0.19.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/PKG-INFO +1 -1
  2. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/pyproject.toml +1 -1
  3. shipit_cli-0.19.4/src/shipit/assets/wordpress/install.sh +125 -0
  4. shipit_cli-0.19.4/src/shipit/version.py +5 -0
  5. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/volumes.py +1 -1
  6. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/tests/test_e2e.py +558 -52
  7. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/tests/test_volumes.py +21 -0
  8. shipit_cli-0.19.3/src/shipit/assets/wordpress/install.sh +0 -113
  9. shipit_cli-0.19.3/src/shipit/version.py +0 -5
  10. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/.gitignore +0 -0
  11. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/README.md +0 -0
  12. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/__init__.py +0 -0
  13. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/assets/php/php.ini +0 -0
  14. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/assets/wordpress/.htaccess +0 -0
  15. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/assets/wordpress/start.php +0 -0
  16. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/assets/wordpress/wp-config.php +0 -0
  17. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/builders/__init__.py +0 -0
  18. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/builders/base.py +0 -0
  19. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/builders/docker.py +0 -0
  20. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/builders/local.py +0 -0
  21. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/cli.py +0 -0
  22. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/generator.py +0 -0
  23. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/procfile.py +0 -0
  24. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/providers/base.py +0 -0
  25. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/providers/go.py +0 -0
  26. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/providers/hugo.py +0 -0
  27. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/providers/jekyll.py +0 -0
  28. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/providers/laravel.py +0 -0
  29. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/providers/mkdocs.py +0 -0
  30. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/providers/node_static.py +0 -0
  31. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/providers/php.py +0 -0
  32. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/providers/python.py +0 -0
  33. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/providers/registry.py +0 -0
  34. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/providers/staticfile.py +0 -0
  35. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/providers/wordpress.py +0 -0
  36. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/runners/__init__.py +0 -0
  37. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/runners/base.py +0 -0
  38. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/runners/local.py +0 -0
  39. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/runners/wasmer.py +0 -0
  40. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/shipit_types.py +0 -0
  41. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/ui.py +0 -0
  42. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/src/shipit/utils.py +0 -0
  43. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/tests/test_cli_after_deploy.py +0 -0
  44. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/tests/test_generate_shipit_examples.py +0 -0
  45. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/tests/test_php_provider.py +0 -0
  46. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/tests/test_staticfile_provider.py +0 -0
  47. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/tests/test_version.py +0 -0
  48. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/tests/test_wasmer_annotations.py +0 -0
  49. {shipit_cli-0.19.3 → shipit_cli-0.19.4}/tests/test_wordpress_phpix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shipit-cli
3
- Version: 0.19.3
3
+ Version: 0.19.4
4
4
  Summary: Shipit CLI is the best way to build, serve and deploy your projects anywhere.
5
5
  Project-URL: homepage, https://wasmer.io
6
6
  Project-URL: repository, https://github.com/wasmerio/shipit
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shipit-cli"
3
- version = "0.19.3"
3
+ version = "0.19.4"
4
4
  description = "Shipit CLI is the best way to build, serve and deploy your projects anywhere."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,125 @@
1
+ # Needed to get the WP-CLI commands to avoid asking for the TTY size
2
+ IFS=$'\n\t'
3
+
4
+ export COLUMNS=80 # Prevent WP-CLI from asking for TTY size
5
+ export PAGER="cat"
6
+
7
+ WP_ADMIN_EMAIL=${WP_ADMIN_EMAIL:-"admin@example.com"}
8
+ WP_ADMIN_USERNAME=${WP_ADMIN_USERNAME:-"admin"}
9
+ WP_ADMIN_PASSWORD=${WP_ADMIN_PASSWORD:-"admin"}
10
+ WP_LOCALE=${WP_LOCALE:-"en_US"}
11
+ WP_SITEURL=${WP_SITEURL:-"http://localhost"}
12
+ WP_SITE_TITLE=${WP_SITE_TITLE:-"WordPress"}
13
+
14
+ if wp core is-installed; then
15
+ echo "🚀 Setting up WordPress from an existing installation"
16
+ if [ "${WP_UPDATE_DB:-true}" = "true" ]; then
17
+ echo "🛠️ Activating maintenance mode..."
18
+ wp maintenance-mode activate || true
19
+ echo "🔄 Updating database..."
20
+ wp core update-db
21
+ echo "🛠️ Deactivating maintenance mode..."
22
+ wp maintenance-mode deactivate || true
23
+ fi
24
+ else
25
+ echo "🚀 Setting up WordPress from a fresh install"
26
+ echo "📁 Initializing wp-content..."
27
+
28
+ mkdir -p wp-content/plugins
29
+ mkdir -p wp-content/upgrade
30
+
31
+ if [ -n "${WPCONTENT_BASE_PATH:-}" ] && [ -d "${WPCONTENT_BASE_PATH}" ]; then
32
+ shopt -s dotglob nullglob
33
+ cp -R "${WPCONTENT_BASE_PATH}"/* /app/wp-content
34
+ shopt -u dotglob nullglob
35
+ fi
36
+
37
+ echo "⚙️ Installing WordPress core"
38
+
39
+ wp core install \
40
+ --url="$WP_SITEURL" \
41
+ --title="$WP_SITE_TITLE" \
42
+ --admin_user="$WP_ADMIN_USERNAME" \
43
+ --admin_password="$WP_ADMIN_PASSWORD" \
44
+ --admin_email="$WP_ADMIN_EMAIL" \
45
+ --locale="$WP_LOCALE"
46
+
47
+ echo "🔄 Setting permalinks"
48
+ wp rewrite structure '/%year%/%monthnum%/%day%/%postname%/'
49
+ fi
50
+
51
+ # Install plugins from WP_PLUGINS environment variable
52
+ if [ -n "${WP_PLUGINS:-}" ]; then
53
+ echo "🛠️ Installing plugins from WP_PLUGINS: $WP_PLUGINS"
54
+
55
+ IFS=',' # Split by commas
56
+ for PLUGIN_ENTRY in $WP_PLUGINS; do
57
+ if [[ "$PLUGIN_ENTRY" =~ ^https?:// ]]; then
58
+ echo "• Installing plugin from URL: $PLUGIN_ENTRY"
59
+ wp plugin install "$PLUGIN_ENTRY" --activate
60
+ else
61
+ # Extract name and version using parameter expansion
62
+ PLUGIN_NAME="${PLUGIN_ENTRY%%:*}"
63
+ PLUGIN_VERSION="${PLUGIN_ENTRY#*:}"
64
+
65
+ if [[ "$PLUGIN_NAME" == "$PLUGIN_VERSION" ]]; then
66
+ echo "• Installing plugin '${PLUGIN_NAME}' (latest version)..."
67
+ wp plugin install "$PLUGIN_NAME" --activate
68
+ else
69
+ echo "• Installing plugin '${PLUGIN_NAME}' (version: ${PLUGIN_VERSION})..."
70
+ wp plugin install "$PLUGIN_NAME" --version="$PLUGIN_VERSION" --activate
71
+ fi
72
+ fi
73
+ done
74
+ fi
75
+
76
+ # Install themes from WP_THEMES environment variable
77
+ if [ -n "${WP_THEMES:-}" ]; then
78
+ echo "🎨 Installing themes from WP_THEMES: $WP_THEMES"
79
+ IFS=','
80
+
81
+ for THEME_ENTRY in $WP_THEMES; do
82
+ if [[ "$THEME_ENTRY" =~ ^https?:// ]]; then
83
+ echo "• Installing theme from URL: $THEME_ENTRY"
84
+ wp theme install "$THEME_ENTRY"
85
+ else
86
+ THEME_NAME="${THEME_ENTRY%%:*}"
87
+ THEME_VERSION="${THEME_ENTRY#*:}"
88
+
89
+ if [[ "$THEME_NAME" == "$THEME_VERSION" ]]; then
90
+ echo "• Installing theme '${THEME_NAME}' (latest version)..."
91
+ wp theme install "$THEME_NAME"
92
+ else
93
+ echo "• Installing theme '${THEME_NAME}' (version: ${THEME_VERSION})..."
94
+ wp theme install "$THEME_NAME" --version="$THEME_VERSION"
95
+ fi
96
+ fi
97
+ done
98
+ fi
99
+
100
+ if [ -n "${WP_DEFAULT_THEME:-}" ]; then
101
+ echo "✨ Activating default theme: $WP_DEFAULT_THEME"
102
+ wp theme activate "$WP_DEFAULT_THEME"
103
+ fi
104
+
105
+ if [ -n "${WP_LOCALE:-}" ]; then
106
+ echo "🌐 Setting locale: $WP_LOCALE"
107
+ wp language core install "$WP_LOCALE"
108
+ wp language theme install --all "$WP_LOCALE"
109
+ wp language plugin install --all "$WP_LOCALE"
110
+ wp site switch-language "$WP_LOCALE"
111
+ fi
112
+
113
+ # echo "✍️ Rewriting permalinks structure"
114
+ # wp rewrite flush --hard || true
115
+
116
+ # if [ ! -f "/app/wp-content/wp-config.php" ]; then
117
+ # cat > /app/wp-content/wp-config.php <<EOF
118
+ # <?php
119
+ # // If you need to set custom configuration, you can place it here.
120
+ # // This file will be included by the main wp-config.php after
121
+ # // loading environment variables.
122
+ # EOF
123
+ # fi
124
+
125
+ echo "✅ WordPress Setup complete"
@@ -0,0 +1,5 @@
1
+ __all__ = ["version", "version_info"]
2
+
3
+
4
+ version = "0.19.4"
5
+ version_info = (0, 19, 4, "final", 0)
@@ -72,7 +72,7 @@ def volume_mapdir_args(
72
72
  for name, guest_path in volume_mappings.items():
73
73
  host_path = build_backend.get_volume_path(name).absolute()
74
74
  host_path.mkdir(parents=True, exist_ok=True)
75
- args.append(f"--volume={host_path}:{guest_path}")
75
+ args.append(f"--volume={host_path.resolve()}:{guest_path}")
76
76
  return args
77
77
 
78
78
 
@@ -1,21 +1,27 @@
1
+ import asyncio
2
+ import contextlib
1
3
  import os
2
4
  import random
3
- import socket
5
+ import re
6
+ import shlex
7
+ import shutil
4
8
  import signal
5
- import asyncio
9
+ import socket
6
10
  import subprocess
7
- import re
11
+ import sys
12
+ import tempfile
13
+ import uuid
14
+ import zipfile
15
+ from dataclasses import dataclass, field
16
+ from enum import Enum
8
17
  from pathlib import Path
9
- from typing import List, NamedTuple
10
- from dataclasses import dataclass
18
+ from typing import List
19
+ from urllib.parse import urlparse
20
+ from urllib.request import urlopen
11
21
 
12
- import pytest
13
- import shlex
14
- import shutil
15
- import contextlib
16
22
  import aiohttp
23
+ import pytest
17
24
  import yaml
18
- from enum import Enum
19
25
 
20
26
 
21
27
  class BuildMode(Enum):
@@ -34,21 +40,50 @@ class HTTPRequest:
34
40
  follow_redirects: bool = True
35
41
 
36
42
 
37
- class E2ECase(NamedTuple):
38
- path: str
43
+ @dataclass(frozen=True)
44
+ class RunCommand:
45
+ command: str
46
+ stdout_match: str | None = None
47
+ stderr_match: str | None = None
48
+ expected_returncode: int = 0
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class CompletedCommand:
53
+ returncode: int | None
54
+ stdout: str
55
+ stderr: str
56
+
57
+ @property
58
+ def output(self) -> str:
59
+ return f"[stdout]\n{self.stdout}\n[stderr]\n{self.stderr}"
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class E2ECase:
39
64
  serve_pattern: str
40
65
  http: List[HTTPRequest]
66
+ path: str | None = None
67
+ download: str | None = None
41
68
  use_random_port: bool = True
69
+ env: dict[str, str] | None = None
42
70
  extra_env: dict[str, str] | None = None
71
+ create_db: bool = False
72
+ create_wp_content_volume: bool = False
73
+ run_after_deploy: bool = False
74
+ commands: list[RunCommand] = field(default_factory=list)
43
75
  expected_memory_limit: str | None = None
44
76
  expect_no_memory_limit: bool = False
45
77
  build_modes: tuple[BuildMode, ...] | None = None
46
78
 
47
79
  def __str__(self):
48
- return self.path
80
+ if self.path:
81
+ return self.path
82
+ assert self.download is not None
83
+ return Path(urlparse(self.download).path).stem
49
84
 
50
85
  def __repr__(self):
51
- return self.path
86
+ return str(self)
52
87
 
53
88
 
54
89
  @pytest.mark.e2e
@@ -85,7 +120,10 @@ class E2ECase(NamedTuple):
85
120
  r"PHP 8\.3\.[0-9]+ Development Server \(http://localhost:[\d]+\) started"
86
121
  ),
87
122
  http=[
88
- HTTPRequest(path="/", body_match=r"\"version\"\s*:\s*\"8\.3\.[0-9]+\""),
123
+ HTTPRequest(
124
+ path="/",
125
+ body_match=r"\"version\"\s*:\s*\"8\.3\.[0-9]+\"",
126
+ ),
89
127
  HTTPRequest(path="/api/greet/Alice", body_match=r"Hello, Alice!"),
90
128
  ],
91
129
  ),
@@ -97,6 +135,39 @@ class E2ECase(NamedTuple):
97
135
  ),
98
136
  http=[HTTPRequest(path="/", body_match=r"WordPress")],
99
137
  ),
138
+ # Full WordPress release archive, built and run through Wasmer only.
139
+ E2ECase(
140
+ download="https://wordpress.org/wordpress-6.9.4.zip",
141
+ serve_pattern=(
142
+ r"listening addr"
143
+ ),
144
+ http=[
145
+ HTTPRequest(
146
+ path="/",
147
+ expected_status=200,
148
+ body_match=r"WordPress",
149
+ )
150
+ ],
151
+ use_random_port=False,
152
+ env={
153
+ "DB_NAME": "test",
154
+ "DB_USERNAME": "root",
155
+ "DB_HOST": "127.0.0.1",
156
+ "DB_PORT": "3306",
157
+ "DB_PASSWORD": "",
158
+ "SHIPIT_PHPIX": "true",
159
+ },
160
+ create_db=True,
161
+ create_wp_content_volume=True,
162
+ run_after_deploy=True,
163
+ commands=[
164
+ RunCommand(
165
+ "wp eval 'echo json_encode([\"status\" => \"ok\"]);'",
166
+ stdout_match=r'\{"status":"ok"\}',
167
+ )
168
+ ],
169
+ build_modes=(BuildMode.Wasmer,),
170
+ ),
100
171
  # WordPress skeleton in phpix mode (Wasmer only), validate memory cap.
101
172
  E2ECase(
102
173
  path="examples/php-wordpress",
@@ -226,7 +297,11 @@ class E2ECase(NamedTuple):
226
297
  BuildMode.WasmerAndDocker,
227
298
  ],
228
299
  )
229
- async def test_end_to_end(case: E2ECase, build_mode: BuildMode):
300
+ async def test_end_to_end(
301
+ case: E2ECase,
302
+ build_mode: BuildMode,
303
+ tmp_path: Path,
304
+ ):
230
305
  # Skip if `uv` is not available in PATH
231
306
  if not shutil.which("uv"):
232
307
  pytest.skip("`uv` is not available in PATH")
@@ -238,29 +313,125 @@ async def test_end_to_end(case: E2ECase, build_mode: BuildMode):
238
313
  pytest.skip("case is not enabled for this build mode")
239
314
 
240
315
  repo_root = Path(__file__).resolve().parents[1]
316
+ project_path = await _materialize_case(case, repo_root, tmp_path)
241
317
 
242
- cmd = [
243
- "uv",
244
- "run",
245
- "shipit-cli",
246
- case.path,
247
- "--skip-prepare",
248
- "--start",
249
- # "--wasmer",
250
- # "--docker",
251
- "--regenerate",
252
- ]
253
- if build_mode == BuildMode.Wasmer:
254
- cmd.append("--wasmer")
255
- cmd.append("--wasmer-registry=wasmer.wtf")
256
- elif build_mode == BuildMode.WasmerAndDocker:
257
- cmd.append("--wasmer")
258
- cmd.append("--wasmer-registry=wasmer.wtf")
259
- cmd.append("--docker")
260
- elif build_mode == BuildMode.Local:
261
- # The default
262
- pass
318
+ if case.use_random_port:
319
+ port = get_free_port()
320
+ else:
321
+ port = 8080 # This is the default port if not specified
322
+
323
+ env = os.environ.copy()
324
+ if case.env:
325
+ env.update(case.env)
326
+ if case.extra_env:
327
+ env.update(case.extra_env)
328
+
329
+ created_db_name = None
330
+ wp_content_volume_dir = None
331
+ try:
332
+ if case.create_db:
333
+ created_db_name = await _create_mysql_database(env)
334
+ env["DB_NAME"] = created_db_name
335
+ volume_specs = None
336
+ if case.create_wp_content_volume:
337
+ wp_content_volume_dir = _create_wp_content_volume(project_path)
338
+ volume_specs = ["wp-content:/app/wp-content"]
339
+
340
+ if case.download or case.commands:
341
+ build_cmd = _shipit_build_command(project_path, build_mode, port)
342
+ build_result = await _run_completed_command(
343
+ build_cmd,
344
+ cwd=repo_root,
345
+ env=env,
346
+ timeout=180,
347
+ )
348
+ build_output = build_result.output
349
+ if build_result.returncode != 0 or "Build complete ✅" not in build_output:
350
+ pytest.fail(
351
+ "End-to-end build command failed.\n"
352
+ f"command={shlex.join(build_cmd)}\n"
353
+ f"returncode={build_result.returncode}\n\n"
354
+ f"--- Captured output start ---\n{build_output}\n"
355
+ "--- Captured output end ---"
356
+ )
263
357
 
358
+ run_cmd = _shipit_run_command(
359
+ project_path,
360
+ build_mode,
361
+ run_after_deploy=case.run_after_deploy,
362
+ start=True,
363
+ volume_specs=volume_specs,
364
+ )
365
+ await _run_server_and_check(
366
+ case=case,
367
+ cmd=run_cmd,
368
+ cwd=repo_root,
369
+ env=env,
370
+ project_path=project_path,
371
+ port=port,
372
+ expect_build=False,
373
+ )
374
+ for command in case.commands:
375
+ cmd = _shipit_run_command(
376
+ project_path,
377
+ build_mode,
378
+ command=command.command,
379
+ volume_specs=volume_specs,
380
+ )
381
+ result = await _run_completed_command(
382
+ cmd,
383
+ cwd=repo_root,
384
+ env=env,
385
+ timeout=180,
386
+ )
387
+ _assert_run_command(command, cmd, result)
388
+ return
389
+
390
+ cmd = _shipit_auto_command(
391
+ project_path,
392
+ build_mode,
393
+ port,
394
+ run_after_deploy=case.run_after_deploy,
395
+ )
396
+ await _run_server_and_check(
397
+ case=case,
398
+ cmd=cmd,
399
+ cwd=repo_root,
400
+ env=env,
401
+ project_path=project_path,
402
+ port=port,
403
+ expect_build=True,
404
+ )
405
+ finally:
406
+ if created_db_name:
407
+ drop_result = await _drop_mysql_database(env, created_db_name)
408
+ if drop_result.returncode != 0:
409
+ drop_error = (
410
+ "Failed to drop temporary MySQL database.\n"
411
+ f"database={created_db_name}\n\n"
412
+ f"--- Captured output start ---\n{drop_result.output}\n"
413
+ "--- Captured output end ---"
414
+ )
415
+ if sys.exc_info()[0] is None:
416
+ try:
417
+ if wp_content_volume_dir:
418
+ shutil.rmtree(wp_content_volume_dir, ignore_errors=True)
419
+ finally:
420
+ pytest.fail(drop_error)
421
+ print(drop_error)
422
+ if wp_content_volume_dir:
423
+ shutil.rmtree(wp_content_volume_dir, ignore_errors=True)
424
+
425
+
426
+ async def _run_server_and_check(
427
+ case: E2ECase,
428
+ cmd: list[str],
429
+ cwd: Path,
430
+ env: dict[str, str],
431
+ project_path: Path,
432
+ port: int,
433
+ expect_build: bool,
434
+ ) -> None:
264
435
  build_phrase = "Build complete ✅"
265
436
  serve_re = re.compile(case.serve_pattern)
266
437
 
@@ -268,19 +439,9 @@ async def test_end_to_end(case: E2ECase, build_mode: BuildMode):
268
439
  start_new_session = os.name != "nt"
269
440
  creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
270
441
 
271
- env = os.environ.copy()
272
- if case.extra_env:
273
- env.update(case.extra_env)
274
- if case.use_random_port:
275
- port = get_free_port()
276
- else:
277
- port = 8080 # This is the default port if not specified
278
-
279
- cmd.append(f"--serve-port={port}")
280
-
281
442
  proc = await asyncio.create_subprocess_exec(
282
443
  *cmd,
283
- cwd=str(repo_root),
444
+ cwd=str(cwd),
284
445
  env=env,
285
446
  stdout=asyncio.subprocess.PIPE,
286
447
  stderr=asyncio.subprocess.PIPE,
@@ -290,9 +451,14 @@ async def test_end_to_end(case: E2ECase, build_mode: BuildMode):
290
451
 
291
452
  output_lines: List[str] = []
292
453
  found_build = asyncio.Event()
454
+ if not expect_build:
455
+ found_build.set()
293
456
  found_serve = asyncio.Event()
457
+ matched_serve_output = False
458
+ verified_http_ready = False
294
459
 
295
460
  async def reader(label: str, stream: asyncio.StreamReader) -> None:
461
+ nonlocal matched_serve_output
296
462
  async for line in stream:
297
463
  line = line.decode("utf-8", errors="replace")
298
464
  print(f"[{label}] {line}", end="")
@@ -300,6 +466,7 @@ async def test_end_to_end(case: E2ECase, build_mode: BuildMode):
300
466
  if (not found_build.is_set()) and (build_phrase in line):
301
467
  found_build.set()
302
468
  if (not found_serve.is_set()) and serve_re.search(line):
469
+ matched_serve_output = True
303
470
  found_serve.set()
304
471
 
305
472
  assert proc.stdout is not None and proc.stderr is not None
@@ -313,6 +480,21 @@ async def test_end_to_end(case: E2ECase, build_mode: BuildMode):
313
480
  while loop.time() < end:
314
481
  if found_build.is_set() and found_serve.is_set():
315
482
  break
483
+ if (
484
+ found_build.is_set()
485
+ and not found_serve.is_set()
486
+ and case.http
487
+ ):
488
+ readiness_request = _http_readiness_request(case.http[0])
489
+ verified_http_ready = await _wait_for_http_response(
490
+ host="localhost",
491
+ port=port,
492
+ request=readiness_request,
493
+ timeout=0.5,
494
+ )
495
+ if verified_http_ready:
496
+ found_serve.set()
497
+ break
316
498
  if proc.returncode is not None:
317
499
  # Process ended early; stop waiting
318
500
  break
@@ -323,7 +505,7 @@ async def test_end_to_end(case: E2ECase, build_mode: BuildMode):
323
505
  if found_serve.is_set():
324
506
  if case.expected_memory_limit or case.expect_no_memory_limit:
325
507
  app_yaml_path = (
326
- repo_root / case.path / ".shipit" / "wasmer" / "app.yaml"
508
+ project_path / ".shipit" / "wasmer" / "app.yaml"
327
509
  )
328
510
  if not app_yaml_path.is_file():
329
511
  full_output = "".join(output_lines)
@@ -416,8 +598,331 @@ async def test_end_to_end(case: E2ECase, build_mode: BuildMode):
416
598
  f"--- Captured output start ---\n{full_output}\n--- Captured output end ---"
417
599
  )
418
600
 
419
- assert build_phrase in full_output
420
- assert serve_re.search(full_output), "Serve banner regex not found in output"
601
+ if expect_build:
602
+ assert build_phrase in full_output
603
+ assert matched_serve_output or verified_http_ready, (
604
+ "Serve banner regex not found in output and HTTP readiness did not pass"
605
+ )
606
+
607
+
608
+ async def _materialize_case(
609
+ case: E2ECase,
610
+ repo_root: Path,
611
+ tmp_path: Path,
612
+ ) -> Path:
613
+ if case.path and case.download:
614
+ raise ValueError("E2ECase can define either path or download, not both")
615
+ if case.path:
616
+ return repo_root / case.path
617
+ if not case.download:
618
+ raise ValueError("E2ECase requires either path or download")
619
+ return await asyncio.to_thread(
620
+ _download_and_extract_archive,
621
+ case.download,
622
+ tmp_path,
623
+ )
624
+
625
+
626
+ def _download_and_extract_archive(url: str, tmp_path: Path) -> Path:
627
+ download_dir = tmp_path / "download"
628
+ download_dir.mkdir(parents=True, exist_ok=True)
629
+ archive_name = Path(urlparse(url).path).name or "download.zip"
630
+ archive_path = download_dir / archive_name
631
+
632
+ with urlopen(url, timeout=120) as response:
633
+ with archive_path.open("wb") as output:
634
+ shutil.copyfileobj(response, output)
635
+
636
+ extract_dir = tmp_path / "src"
637
+ extract_dir.mkdir(parents=True, exist_ok=True)
638
+ if archive_path.suffix == ".zip":
639
+ _extract_zip(archive_path, extract_dir)
640
+ else:
641
+ shutil.unpack_archive(str(archive_path), str(extract_dir))
642
+
643
+ children = [path for path in extract_dir.iterdir() if path.name != "__MACOSX"]
644
+ if len(children) == 1 and children[0].is_dir():
645
+ return children[0]
646
+ return extract_dir
647
+
648
+
649
+ def _extract_zip(archive_path: Path, extract_dir: Path) -> None:
650
+ extract_root = extract_dir.resolve()
651
+ with zipfile.ZipFile(archive_path) as archive:
652
+ for member in archive.infolist():
653
+ target = (extract_dir / member.filename).resolve()
654
+ if not target.is_relative_to(extract_root):
655
+ raise ValueError(
656
+ f"Archive member escapes extract dir: {member.filename}"
657
+ )
658
+ archive.extractall(extract_dir)
659
+
660
+
661
+ async def _create_mysql_database(env: dict[str, str]) -> str:
662
+ name = f"shipit_e2e_{uuid.uuid4().hex}"
663
+ result = await _run_mysql_sql(
664
+ env,
665
+ (
666
+ f"CREATE DATABASE {_quote_mysql_identifier(name)} "
667
+ "CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
668
+ ),
669
+ )
670
+ if result.returncode != 0:
671
+ pytest.fail(
672
+ "Failed to create temporary MySQL database.\n"
673
+ f"database={name}\n\n"
674
+ f"--- Captured output start ---\n{result.output}\n"
675
+ "--- Captured output end ---"
676
+ )
677
+ return name
678
+
679
+
680
+ async def _drop_mysql_database(
681
+ env: dict[str, str],
682
+ name: str,
683
+ ) -> CompletedCommand:
684
+ return await _run_mysql_sql(
685
+ env,
686
+ f"DROP DATABASE IF EXISTS {_quote_mysql_identifier(name)}",
687
+ )
688
+
689
+
690
+ async def _run_mysql_sql(env: dict[str, str], sql: str) -> CompletedCommand:
691
+ return await _run_completed_command(
692
+ _mysql_command(env, sql),
693
+ cwd=Path(__file__).resolve().parents[1],
694
+ env=env,
695
+ timeout=30,
696
+ )
697
+
698
+
699
+ def _mysql_command(env: dict[str, str], sql: str) -> list[str]:
700
+ mysql = shutil.which("mysql")
701
+ if not mysql:
702
+ pytest.fail(
703
+ "`mysql` client is not available; it is required for "
704
+ "E2ECase(create_db=True)."
705
+ )
706
+
707
+ cmd = [
708
+ mysql,
709
+ "--protocol=TCP",
710
+ "--batch",
711
+ "--skip-column-names",
712
+ "--host",
713
+ env.get("DB_HOST", "127.0.0.1"),
714
+ "--port",
715
+ env.get("DB_PORT", "3306"),
716
+ "--user",
717
+ env.get("DB_USERNAME", "root"),
718
+ ]
719
+ if "DB_PASSWORD" in env:
720
+ cmd.append(f"--password={env['DB_PASSWORD']}")
721
+ cmd.extend(["--execute", sql])
722
+ return cmd
723
+
724
+
725
+ def _quote_mysql_identifier(name: str) -> str:
726
+ if not re.fullmatch(r"[A-Za-z0-9_]+", name):
727
+ raise ValueError(f"Invalid MySQL identifier: {name!r}")
728
+ return f"`{name}`"
729
+
730
+
731
+ def _create_wp_content_volume(project_path: Path) -> Path:
732
+ host_dir = Path(
733
+ tempfile.mkdtemp(prefix="shipit-e2e-wp-content-", dir="/tmp")
734
+ )
735
+ volume_path = project_path / ".shipit" / "volumes" / "wp-content"
736
+ volume_path.parent.mkdir(parents=True, exist_ok=True)
737
+ if volume_path.is_symlink() or volume_path.is_file():
738
+ volume_path.unlink()
739
+ elif volume_path.is_dir():
740
+ shutil.rmtree(volume_path)
741
+ volume_path.symlink_to(host_dir, target_is_directory=True)
742
+ return host_dir
743
+
744
+
745
+ def _shipit_auto_command(
746
+ project_path: Path,
747
+ build_mode: BuildMode,
748
+ port: int,
749
+ run_after_deploy: bool,
750
+ ) -> list[str]:
751
+ cmd = [
752
+ "uv",
753
+ "run",
754
+ "shipit",
755
+ str(project_path),
756
+ "--skip-prepare",
757
+ "--start",
758
+ "--regenerate",
759
+ ]
760
+ if run_after_deploy:
761
+ cmd.append("--after-deploy")
762
+ _append_build_mode_flags(cmd, build_mode)
763
+ cmd.append(f"--serve-port={port}")
764
+ return cmd
765
+
766
+
767
+ def _shipit_build_command(
768
+ project_path: Path,
769
+ build_mode: BuildMode,
770
+ port: int,
771
+ ) -> list[str]:
772
+ cmd = [
773
+ "uv",
774
+ "run",
775
+ "shipit",
776
+ str(project_path),
777
+ "--skip-prepare",
778
+ "--regenerate",
779
+ ]
780
+ _append_build_mode_flags(cmd, build_mode)
781
+ cmd.append(f"--serve-port={port}")
782
+ return cmd
783
+
784
+
785
+ def _shipit_run_command(
786
+ project_path: Path,
787
+ build_mode: BuildMode,
788
+ *,
789
+ run_after_deploy: bool = False,
790
+ start: bool = False,
791
+ command: str | None = None,
792
+ volume_specs: list[str] | None = None,
793
+ ) -> list[str]:
794
+ cmd = [
795
+ "uv",
796
+ "run",
797
+ "shipit",
798
+ "run",
799
+ str(project_path),
800
+ ]
801
+ if run_after_deploy:
802
+ cmd.append("--after-deploy")
803
+ if start:
804
+ cmd.append("--start")
805
+ if command:
806
+ cmd.append(f"--command={command}")
807
+ for spec in volume_specs or []:
808
+ cmd.extend(["--volume", spec])
809
+ _append_run_mode_flags(cmd, build_mode)
810
+ return cmd
811
+
812
+
813
+ def _append_build_mode_flags(cmd: list[str], build_mode: BuildMode) -> None:
814
+ if build_mode == BuildMode.Wasmer:
815
+ cmd.append("--wasmer")
816
+ cmd.append("--wasmer-registry=wasmer.wtf")
817
+ elif build_mode == BuildMode.WasmerAndDocker:
818
+ cmd.append("--wasmer")
819
+ cmd.append("--wasmer-registry=wasmer.wtf")
820
+ cmd.append("--docker")
821
+ elif build_mode == BuildMode.Local:
822
+ pass
823
+
824
+
825
+ def _append_run_mode_flags(cmd: list[str], build_mode: BuildMode) -> None:
826
+ if build_mode == BuildMode.Wasmer:
827
+ cmd.append("--wasmer")
828
+ cmd.append("--wasmer-registry=wasmer.wtf")
829
+ elif build_mode == BuildMode.WasmerAndDocker:
830
+ cmd.append("--wasmer")
831
+ cmd.append("--wasmer-registry=wasmer.wtf")
832
+ cmd.append("--docker")
833
+ elif build_mode == BuildMode.Local:
834
+ pass
835
+
836
+
837
+ async def _run_completed_command(
838
+ cmd: list[str],
839
+ cwd: Path,
840
+ env: dict[str, str],
841
+ timeout: float,
842
+ ) -> CompletedCommand:
843
+ start_new_session = os.name != "nt"
844
+ creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
845
+ proc = await asyncio.create_subprocess_exec(
846
+ *cmd,
847
+ cwd=str(cwd),
848
+ env=env,
849
+ stdout=asyncio.subprocess.PIPE,
850
+ stderr=asyncio.subprocess.PIPE,
851
+ start_new_session=start_new_session,
852
+ creationflags=creationflags,
853
+ )
854
+ try:
855
+ stdout, stderr = await asyncio.wait_for(
856
+ proc.communicate(),
857
+ timeout=timeout,
858
+ )
859
+ except asyncio.TimeoutError:
860
+ await _stop_process(proc)
861
+ stdout, stderr = await proc.communicate()
862
+ return CompletedCommand(
863
+ returncode=proc.returncode,
864
+ stdout=stdout.decode("utf-8", errors="replace"),
865
+ stderr=stderr.decode("utf-8", errors="replace"),
866
+ )
867
+
868
+
869
+ async def _stop_process(proc: asyncio.subprocess.Process) -> None:
870
+ try:
871
+ if os.name != "nt":
872
+ os.killpg(os.getpgid(proc.pid), signal.SIGINT)
873
+ else:
874
+ proc.send_signal(signal.CTRL_BREAK_EVENT)
875
+ except Exception:
876
+ pass
877
+ try:
878
+ await asyncio.wait_for(proc.wait(), timeout=2)
879
+ except asyncio.TimeoutError:
880
+ if os.name != "nt":
881
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
882
+ else:
883
+ proc.kill()
884
+ await proc.wait()
885
+
886
+
887
+ def _assert_run_command(
888
+ command: RunCommand,
889
+ cmd: list[str],
890
+ result: CompletedCommand,
891
+ ) -> None:
892
+ if result.returncode != command.expected_returncode:
893
+ pytest.fail(
894
+ "Run command exited with unexpected status.\n"
895
+ f"command={shlex.join(cmd)}\n"
896
+ f"expected_returncode={command.expected_returncode}\n"
897
+ f"returncode={result.returncode}\n\n"
898
+ f"--- Captured output start ---\n{result.output}\n"
899
+ "--- Captured output end ---"
900
+ )
901
+ if command.stdout_match and not re.search(command.stdout_match, result.stdout):
902
+ pytest.fail(
903
+ "Run command stdout did not match expected regex.\n"
904
+ f"command={shlex.join(cmd)}\n"
905
+ f"stdout_match={command.stdout_match!r}\n\n"
906
+ f"--- Captured output start ---\n{result.output}\n"
907
+ "--- Captured output end ---"
908
+ )
909
+ if command.stderr_match and not re.search(command.stderr_match, result.stderr):
910
+ pytest.fail(
911
+ "Run command stderr did not match expected regex.\n"
912
+ f"command={shlex.join(cmd)}\n"
913
+ f"stderr_match={command.stderr_match!r}\n\n"
914
+ f"--- Captured output start ---\n{result.output}\n"
915
+ "--- Captured output end ---"
916
+ )
917
+
918
+
919
+ def _http_readiness_request(request: HTTPRequest) -> HTTPRequest:
920
+ return HTTPRequest(
921
+ path=request.path,
922
+ method=request.method,
923
+ expected_status=request.expected_status or 200,
924
+ follow_redirects=request.follow_redirects,
925
+ )
421
926
 
422
927
 
423
928
  async def _wait_for_http_response(
@@ -426,8 +931,9 @@ async def _wait_for_http_response(
426
931
  url = f"http://{host}:{port}{request.path}"
427
932
  loop = asyncio.get_running_loop()
428
933
  end = loop.time() + timeout
934
+ request_timeout = max(0.2, min(5.0, timeout))
429
935
  async with aiohttp.ClientSession(
430
- timeout=aiohttp.ClientTimeout(total=5.0)
936
+ timeout=aiohttp.ClientTimeout(total=request_timeout)
431
937
  ) as session:
432
938
  while loop.time() < end:
433
939
  try:
@@ -12,6 +12,7 @@ from shipit.volumes import (
12
12
  load_volume_mappings,
13
13
  merge_volume_mappings,
14
14
  parse_cli_volume_mappings,
15
+ volume_mapdir_args,
15
16
  )
16
17
 
17
18
 
@@ -139,6 +140,26 @@ def test_wasmer_runner_passes_volume_paths_into_wasmer_run(
139
140
  )
140
141
 
141
142
 
143
+ def test_volume_mapdir_args_resolves_symlinked_volume_paths(
144
+ tmp_path: Path,
145
+ ) -> None:
146
+ src_dir = tmp_path / "src"
147
+ src_dir.mkdir()
148
+ host_volume = tmp_path / "host-wp-content"
149
+ host_volume.mkdir()
150
+ backend = LocalBuildBackend(src_dir, tmp_path / "assets")
151
+ volume_link = backend.get_volume_path("wp-content")
152
+ volume_link.parent.mkdir(parents=True)
153
+ volume_link.symlink_to(host_volume, target_is_directory=True)
154
+
155
+ args = volume_mapdir_args(
156
+ backend,
157
+ {"wp-content": "/app/wp-content"},
158
+ )
159
+
160
+ assert args == [f"--volume={host_volume.resolve()}:/app/wp-content"]
161
+
162
+
142
163
  def test_parse_cli_volume_mappings() -> None:
143
164
  assert parse_cli_volume_mappings(
144
165
  ["uploads:/app/uploads", "cache:/app/cache"]
@@ -1,113 +0,0 @@
1
- # Needed to get the WP-CLI commands to avoid asking for the TTY size
2
- IFS=$'\n\t'
3
-
4
- export COLUMNS=80 # Prevent WP-CLI from asking for TTY size
5
- export PAGER="cat"
6
-
7
- WP_ADMIN_EMAIL=${WP_ADMIN_EMAIL:-"admin@example.com"}
8
- WP_ADMIN_USERNAME=${WP_ADMIN_USERNAME:-"admin"}
9
- WP_ADMIN_PASSWORD=${WP_ADMIN_PASSWORD:-"admin"}
10
- WP_LOCALE=${WP_LOCALE:-"en_US"}
11
- WP_SITEURL=${WP_SITEURL:-"http://localhost"}
12
- WP_SITE_TITLE=${WP_SITE_TITLE:-"WordPress"}
13
-
14
- echo "🚀 Starting WordPress setup..."
15
-
16
- echo "Creating required directories..."
17
-
18
- mkdir -p wp-content/plugins
19
- mkdir -p wp-content/upgrade
20
-
21
- if [ -n "${WPCONTENT_BASE_PATH:-}" ] && [ -d "${WPCONTENT_BASE_PATH}" ]; then
22
- shopt -s dotglob nullglob
23
- cp -R "${WPCONTENT_BASE_PATH}"/* /app/wp-content
24
- shopt -u dotglob nullglob
25
- fi
26
-
27
- echo "Installing WordPress core"
28
-
29
- wp core install \
30
- --url="$WP_SITEURL" \
31
- --title="$WP_SITE_TITLE" \
32
- --admin_user="$WP_ADMIN_USERNAME" \
33
- --admin_password="$WP_ADMIN_PASSWORD" \
34
- --admin_email="$WP_ADMIN_EMAIL" \
35
- --locale="$WP_LOCALE"
36
-
37
- if [ "${WP_UPDATE_DB:-false}" = "true" ]; then
38
- echo "Updating database..."
39
- wp core update-db
40
- fi
41
-
42
- # Install plugins from WP_PLUGINS environment variable
43
- if [ -n "${WP_PLUGINS:-}" ]; then
44
- echo "Installing plugins from WP_PLUGINS: $WP_PLUGINS"
45
-
46
- IFS=',' # Split by commas
47
- for PLUGIN_ENTRY in $WP_PLUGINS; do
48
- if [[ "$PLUGIN_ENTRY" =~ ^https?:// ]]; then
49
- echo "Installing plugin from URL: $PLUGIN_ENTRY"
50
- wp plugin install "$PLUGIN_ENTRY" --activate
51
- else
52
- # Extract name and version using parameter expansion
53
- PLUGIN_NAME="${PLUGIN_ENTRY%%:*}"
54
- PLUGIN_VERSION="${PLUGIN_ENTRY#*:}"
55
-
56
- if [[ "$PLUGIN_NAME" == "$PLUGIN_VERSION" ]]; then
57
- echo "Installing plugin '${PLUGIN_NAME}' (latest version)..."
58
- wp plugin install "$PLUGIN_NAME" --activate
59
- else
60
- echo "Installing plugin '${PLUGIN_NAME}' (version: ${PLUGIN_VERSION})..."
61
- wp plugin install "$PLUGIN_NAME" --version="$PLUGIN_VERSION" --activate
62
- fi
63
- fi
64
- done
65
- fi
66
-
67
- # Install themes from WP_THEMES environment variable
68
- if [ -n "${WP_THEMES:-}" ]; then
69
- echo "🎨 Installing themes from WP_THEMES: $WP_THEMES"
70
- IFS=','
71
-
72
- for THEME_ENTRY in $WP_THEMES; do
73
- if [[ "$THEME_ENTRY" =~ ^https?:// ]]; then
74
- echo "Installing theme from URL: $THEME_ENTRY"
75
- wp theme install "$THEME_ENTRY"
76
- else
77
- THEME_NAME="${THEME_ENTRY%%:*}"
78
- THEME_VERSION="${THEME_ENTRY#*:}"
79
-
80
- if [[ "$THEME_NAME" == "$THEME_VERSION" ]]; then
81
- echo "Installing theme '${THEME_NAME}' (latest version)..."
82
- wp theme install "$THEME_NAME"
83
- else
84
- echo "Installing theme '${THEME_NAME}' (version: ${THEME_VERSION})..."
85
- wp theme install "$THEME_NAME" --version="$THEME_VERSION"
86
- fi
87
- fi
88
- done
89
- fi
90
-
91
- if [ -n "${WP_DEFAULT_THEME:-}" ]; then
92
- echo "Activating default theme: $WP_DEFAULT_THEME"
93
- wp theme activate "$WP_DEFAULT_THEME"
94
- fi
95
-
96
- if [ -n "${WP_LOCALE:-}" ]; then
97
- echo "Setting locale: $WP_LOCALE"
98
- wp language core install "$WP_LOCALE"
99
- wp language theme install --all "$WP_LOCALE"
100
- wp language plugin install --all "$WP_LOCALE"
101
- wp site switch-language "$WP_LOCALE"
102
- fi
103
-
104
- if [ ! -f "/app/wp-content/wp-config.php" ]; then
105
- cat > /app/wp-content/wp-config.php <<EOF
106
- <?php
107
- // If you need to set custom configuration, you can place it here.
108
- // This file will be included by the main wp-config.php after
109
- // loading environment variables.
110
- EOF
111
- fi
112
-
113
- echo "✅ WordPress Installation complete"
@@ -1,5 +0,0 @@
1
- __all__ = ["version", "version_info"]
2
-
3
-
4
- version = "0.19.3"
5
- version_info = (0, 19, 3, "final", 0)
File without changes
File without changes