@p10i/create-nestbox 0.1.0

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 (43) hide show
  1. package/.dockerignore +34 -0
  2. package/.env.example +35 -0
  3. package/.gitattributes +10 -0
  4. package/INSTALL.md +258 -0
  5. package/LICENSE +21 -0
  6. package/README.md +41 -0
  7. package/bin/create-nestbox.js +184 -0
  8. package/docker/nginx/Dockerfile +31 -0
  9. package/docker/opencode/Dockerfile +36 -0
  10. package/docker/opencode/entrypoint.sh +9 -0
  11. package/docker/php-fpm/Dockerfile +43 -0
  12. package/docker/rollup/Dockerfile +16 -0
  13. package/docker-compose.yaml +139 -0
  14. package/home/code/.user.ini +1 -0
  15. package/home/code/404.php +104 -0
  16. package/home/code/__data/.gitignore +2 -0
  17. package/home/code/__includes/footer.php +3 -0
  18. package/home/code/__includes/functions.php +325 -0
  19. package/home/code/__includes/header.php +20 -0
  20. package/home/code/__templates/super-document.php +47 -0
  21. package/home/code/scripts-bundle.css +13295 -0
  22. package/home/code/scripts-bundle.js +6277 -0
  23. package/home/code/scripts.js +3 -0
  24. package/home/code/styles.css +36 -0
  25. package/home/configs/nginx/nginx.conf +199 -0
  26. package/home/configs/opencode/instance.example.md +16 -0
  27. package/home/configs/opencode/instructions.md +308 -0
  28. package/home/configs/opencode/opencode.json +15 -0
  29. package/home/configs/php/cli.php +33 -0
  30. package/home/configs/php/composer.json +10 -0
  31. package/home/configs/php/composer.lock +805 -0
  32. package/home/configs/php/index.php +105 -0
  33. package/home/configs/php/init.php +3 -0
  34. package/home/configs/php/php.ini +5 -0
  35. package/home/configs/php/preload.php +5 -0
  36. package/home/configs/rollup/package-lock.json +2089 -0
  37. package/home/configs/rollup/package.json +16 -0
  38. package/home/configs/rollup/rollup.config.js +28 -0
  39. package/package.json +42 -0
  40. package/scripts/check-package.js +31 -0
  41. package/template.gitignore +20 -0
  42. package/tests/nestbox.ps1 +322 -0
  43. package/tests/nestbox.sh +438 -0
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -uo pipefail
4
+
5
+ START=0
6
+ TIMEOUT=30
7
+ PAGE_ROOT_ARG=""
8
+ PASSES=0
9
+ FAILURES=0
10
+ SKIPS=0
11
+
12
+ usage() {
13
+ printf 'Usage: %s [--start] [--timeout seconds] [--page-root path]\n' "$0"
14
+ }
15
+
16
+ while (($#)); do
17
+ case "$1" in
18
+ --start)
19
+ START=1
20
+ ;;
21
+ --timeout)
22
+ shift
23
+ TIMEOUT="${1:-}"
24
+ [[ "$TIMEOUT" =~ ^[1-9][0-9]*$ ]] || { usage; exit 2; }
25
+ ;;
26
+ --page-root)
27
+ shift
28
+ PAGE_ROOT_ARG="${1:-}"
29
+ [[ -n "$PAGE_ROOT_ARG" ]] || { usage; exit 2; }
30
+ ;;
31
+ --help|-h)
32
+ usage
33
+ exit 0
34
+ ;;
35
+ *)
36
+ usage
37
+ exit 2
38
+ ;;
39
+ esac
40
+ shift
41
+ done
42
+
43
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
44
+ PAGE_ROOT="${PAGE_ROOT_ARG:-${NESTBOX_PAGE_PATH:-$ROOT/home/code}}"
45
+ if [[ "$PAGE_ROOT" != /* ]]; then
46
+ PAGE_ROOT="$ROOT/$PAGE_ROOT"
47
+ fi
48
+ if [[ -n "$PAGE_ROOT_ARG" ]]; then
49
+ export NESTBOX_PAGE_PATH="$PAGE_ROOT"
50
+ fi
51
+ COMPOSE=(docker compose --project-directory "$ROOT")
52
+ TEMP_DIR=""
53
+ SSE_PID=""
54
+
55
+ cleanup() {
56
+ if [[ -n "$SSE_PID" ]] && kill -0 "$SSE_PID" 2>/dev/null; then
57
+ kill "$SSE_PID" 2>/dev/null || true
58
+ wait "$SSE_PID" 2>/dev/null || true
59
+ fi
60
+ [[ -z "$TEMP_DIR" ]] || rm -rf "$TEMP_DIR"
61
+ }
62
+ trap cleanup EXIT
63
+
64
+ pass() {
65
+ PASSES=$((PASSES + 1))
66
+ printf 'PASS %s\n' "$1"
67
+ }
68
+
69
+ fail() {
70
+ FAILURES=$((FAILURES + 1))
71
+ printf 'FAIL %s\n' "$1" >&2
72
+ }
73
+
74
+ skip() {
75
+ SKIPS=$((SKIPS + 1))
76
+ printf 'SKIP %s\n' "$1"
77
+ }
78
+
79
+ require_command() {
80
+ if command -v "$1" >/dev/null 2>&1; then
81
+ pass "$1 is available"
82
+ else
83
+ fail "$1 is required"
84
+ return 1
85
+ fi
86
+ }
87
+
88
+ compose() {
89
+ "${COMPOSE[@]}" "$@"
90
+ }
91
+
92
+ container_id() {
93
+ compose ps -q "$1" 2>/dev/null
94
+ }
95
+
96
+ container_running() {
97
+ local id
98
+ id="$(container_id "$1")"
99
+ [[ -n "$id" ]] && [[ "$(docker inspect --format '{{.State.Running}}' "$id" 2>/dev/null)" == "true" ]]
100
+ }
101
+
102
+ restart_count() {
103
+ local id
104
+ id="$(container_id "$1")"
105
+ [[ -n "$id" ]] || return 1
106
+ docker inspect --format '{{.RestartCount}}' "$id" 2>/dev/null
107
+ }
108
+
109
+ http_request() {
110
+ local path="$1"
111
+ local host_header="${2:-}"
112
+ local method="${3:-GET}"
113
+ local body_file="$TEMP_DIR/body"
114
+ local header_file="$TEMP_DIR/headers"
115
+ local args=(--silent --show-error --noproxy '*' --max-time "$TIMEOUT" --request "$method" --output "$body_file" --dump-header "$header_file" --write-out '%{http_code}')
116
+ [[ -z "$host_header" ]] || args+=(--header "Host: $host_header")
117
+ curl "${args[@]}" "$BASE_URL$path"
118
+ }
119
+
120
+ header_value() {
121
+ local name="$1"
122
+ local wanted
123
+ wanted="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]'):"
124
+ awk -v wanted="$wanted" '
125
+ { line=$0; sub(/\r$/, "", line); lower=tolower(line) }
126
+ index(lower, wanted) == 1 { sub(/^[^:]*:[[:space:]]*/, "", line); value=line }
127
+ END { print value }
128
+ ' "$TEMP_DIR/headers"
129
+ }
130
+
131
+ if ! require_command docker || ! require_command curl; then
132
+ printf '\n%d passed, %d failed, %d skipped\n' "$PASSES" "$FAILURES" "$SKIPS"
133
+ exit 1
134
+ fi
135
+
136
+ if docker compose version >/dev/null 2>&1; then
137
+ pass 'Docker Compose v2 is available'
138
+ else
139
+ fail 'Docker Compose v2 is required'
140
+ exit 1
141
+ fi
142
+
143
+ TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/nestbox-test.XXXXXX")"
144
+
145
+ [[ -f "$ROOT/.env" ]] && pass '.env exists' || fail '.env is missing'
146
+ [[ ! -d "$ROOT/home/.git" ]] && pass 'Page and configuration tree has no embedded Git repository' || fail 'home/.git must not be included in an installation'
147
+ if [[ -f "$PAGE_ROOT/index.php" ]]; then
148
+ INDEX_PRESENT=1
149
+ pass 'Page tree has an implemented index.php'
150
+ else
151
+ INDEX_PRESENT=0
152
+ pass 'Fresh page tree intentionally has no index.php'
153
+ fi
154
+ grep -Fq 'free of empty lines between its nested or adjacent element tags' "$ROOT/home/configs/opencode/instructions.md" && pass 'OpenCode instructions enforce contiguous raw HTML blocks' || fail 'OpenCode instructions omit the raw HTML blank-line invariant'
155
+ grep -Fq '## PHP To OpenCode API' "$ROOT/home/configs/opencode/instructions.md" && pass 'OpenCode instructions document the PHP API' || fail 'OpenCode instructions omit the PHP API contract'
156
+ grep -Fq '## Extending The Environment' "$ROOT/home/configs/opencode/instructions.md" && pass 'OpenCode instructions document environment extension' || fail 'OpenCode instructions omit environment extension guidance'
157
+ grep -Fq 'deliberately has no Docker socket' "$ROOT/home/configs/opencode/instructions.md" && pass 'OpenCode instructions preserve the host container boundary' || fail 'OpenCode instructions omit the host container boundary'
158
+ [[ -f "$ROOT/home/configs/opencode/instance.example.md" ]] && pass 'OpenCode instance policy template exists' || fail 'OpenCode instance policy template is missing'
159
+ [[ -f "$ROOT/home/configs/opencode/instance.md" ]] && pass 'Installed OpenCode instance policy exists' || fail 'Copy instance.example.md to instance.md before startup'
160
+ grep -Fq 'Commit policy:' "$ROOT/home/configs/opencode/instance.md" && pass 'OpenCode instance policy records commit behavior' || fail 'OpenCode instance policy omits commit behavior'
161
+ grep -Fq 'Communication language:' "$ROOT/home/configs/opencode/instance.md" && pass 'OpenCode instance policy records communication language' || fail 'OpenCode instance policy omits communication language'
162
+ grep -Fq '"instance.md"' "$ROOT/home/configs/opencode/opencode.json" && pass 'OpenCode loads instance policy directly' || fail 'OpenCode does not load instance policy'
163
+
164
+ if compose config --quiet; then
165
+ pass 'Compose configuration is valid'
166
+ else
167
+ fail 'Compose configuration is invalid'
168
+ fi
169
+
170
+ services="$(compose config --services 2>/dev/null || true)"
171
+ for service in nginx php-fpm rollup opencode; do
172
+ if grep -qx "$service" <<<"$services"; then
173
+ pass "Compose defines $service"
174
+ else
175
+ fail "Compose does not define $service"
176
+ fi
177
+ done
178
+ running_services="$(compose ps --services 2>/dev/null || true)"
179
+ while IFS= read -r service; do
180
+ [[ -z "$service" ]] && continue
181
+ if ! grep -qx "$service" <<<"$services"; then
182
+ fail "Compose project contains unexpected service $service; choose a unique COMPOSE_PROJECT_NAME"
183
+ fi
184
+ done <<<"$running_services"
185
+
186
+ if ((START)); then
187
+ if compose build nginx php-fpm rollup opencode; then
188
+ pass 'All service images build'
189
+ else
190
+ fail 'One or more service images failed to build'
191
+ fi
192
+
193
+ # The Rollup watcher intentionally writes generated bundles to the bind
194
+ # mount, so lifecycle mode leaves it untouched and validates its image via
195
+ # the build plus the bundles served by Nginx.
196
+ if compose up -d nginx php-fpm opencode; then
197
+ pass 'Application services started'
198
+ else
199
+ fail 'Application services failed to start'
200
+ fi
201
+ fi
202
+
203
+ for service in nginx php-fpm opencode; do
204
+ if container_running "$service"; then
205
+ pass "$service is running"
206
+ else
207
+ fail "$service is not running"
208
+ fi
209
+ done
210
+
211
+ if [[ -n "$(container_id rollup)" ]]; then
212
+ container_running rollup && pass 'rollup is running' || fail 'rollup exists but is not running'
213
+ else
214
+ skip 'rollup watcher is not running; generated bundles are tested from the image'
215
+ fi
216
+
217
+ NGINX_RESTARTS="$(restart_count nginx 2>/dev/null || printf '')"
218
+ PHP_RESTARTS="$(restart_count php-fpm 2>/dev/null || printf '')"
219
+ OPENCODE_RESTARTS="$(restart_count opencode 2>/dev/null || printf '')"
220
+
221
+ published="$(compose port nginx 80 2>/dev/null || true)"
222
+ if [[ "$published" =~ ^\[(.*)\]:([0-9]+)$ ]]; then
223
+ WEB_HOST="${BASH_REMATCH[1]}"
224
+ WEB_PORT="${BASH_REMATCH[2]}"
225
+ elif [[ "$published" =~ ^(.*):([0-9]+)$ ]]; then
226
+ WEB_HOST="${BASH_REMATCH[1]}"
227
+ WEB_PORT="${BASH_REMATCH[2]}"
228
+ else
229
+ fail 'Could not resolve the published Nginx port'
230
+ printf '\n%d passed, %d failed, %d skipped\n' "$PASSES" "$FAILURES" "$SKIPS"
231
+ exit 1
232
+ fi
233
+
234
+ case "$WEB_HOST" in
235
+ 0.0.0.0|::) WEB_HOST=127.0.0.1 ;;
236
+ esac
237
+ if [[ "$WEB_HOST" == *:* ]]; then
238
+ BASE_URL="http://[$WEB_HOST]:$WEB_PORT"
239
+ else
240
+ BASE_URL="http://$WEB_HOST:$WEB_PORT"
241
+ fi
242
+ pass "Nginx is published at $BASE_URL"
243
+
244
+ deadline=$((SECONDS + TIMEOUT))
245
+ health_status=000
246
+ while ((SECONDS < deadline)); do
247
+ health_status="$(http_request '/_nestbox/health' 2>/dev/null || printf '000')"
248
+ [[ "$health_status" == "200" ]] && break
249
+ sleep 1
250
+ done
251
+ [[ "$health_status" == "200" ]] && pass 'Nginx and PHP health endpoint responds' || fail 'Health endpoint did not become ready'
252
+
253
+ compose exec -T nginx nginx -t >/dev/null 2>&1 && pass 'Nginx configuration is valid' || fail 'Nginx configuration is invalid'
254
+ compose exec -T nginx apk info -e nginx >/dev/null 2>&1 && pass 'Alpine Nginx package is installed' || fail 'Alpine Nginx package is missing'
255
+ compose exec -T nginx apk info -e nginx-mod-http-nchan >/dev/null 2>&1 && pass 'Alpine Nchan package is installed' || fail 'Alpine Nchan package is missing'
256
+ nginx_config="$(compose exec -T nginx nginx -T 2>&1 || true)"
257
+ if grep -q 'ngx_nchan_module.so' <<<"$nginx_config"; then
258
+ pass 'Nchan dynamic module is loaded'
259
+ else
260
+ fail 'Nchan dynamic module is not loaded'
261
+ fi
262
+
263
+ if ((INDEX_PRESENT)); then
264
+ status="$(http_request '/' 2>/dev/null || printf '000')"
265
+ [[ "$status" == "200" ]] && pass 'Main super-document returns HTTP 200' || fail "Main super-document returned HTTP $status"
266
+ content_type="$(header_value 'Content-Type')"
267
+ content_type="$(printf '%s' "$content_type" | tr '[:upper:]' '[:lower:]')"
268
+ [[ "$content_type" == text/html* ]] && pass 'Main super-document is HTML' || fail 'Main super-document has the wrong content type'
269
+ else
270
+ status="$(http_request '/' 2>/dev/null || printf '000')"
271
+ if [[ "$status" == "404" ]] && grep -Fq 'Create and open session' "$TEMP_DIR/body"; then
272
+ pass 'Fresh root renders the OpenCode implementation form with HTTP 404'
273
+ else
274
+ fail "Fresh root did not render the OpenCode implementation form with HTTP 404"
275
+ fi
276
+ fi
277
+ for header in Content-Security-Policy X-Content-Type-Options Referrer-Policy Cache-Control; do
278
+ [[ -n "$(header_value "$header")" ]] && pass "$header is present" || fail "$header is missing"
279
+ done
280
+ [[ "$(header_value 'Cache-Control')" == *no-store* ]] && pass 'Nestbox responses disable browser caching' || fail 'Nestbox responses permit browser caching'
281
+
282
+ if ((INDEX_PRESENT)); then
283
+ status="$(http_request '/index.md' 2>/dev/null || printf '000')"
284
+ [[ "$status" == "200" ]] && pass 'Markdown direction returns HTTP 200' || fail "Markdown direction returned HTTP $status"
285
+ content_type="$(header_value 'Content-Type')"
286
+ content_type="$(printf '%s' "$content_type" | tr '[:upper:]' '[:lower:]')"
287
+ [[ "$content_type" == text/markdown* ]] && pass 'Markdown direction has text/markdown content type' || fail 'Markdown direction has the wrong content type'
288
+ else
289
+ status="$(http_request '/index.md' 2>/dev/null || printf '000')"
290
+ [[ "$status" == "404" ]] && pass 'Fresh index Markdown direction remains unresolved' || fail "Fresh index Markdown direction returned HTTP $status"
291
+ fi
292
+
293
+ for asset in scripts-bundle.js scripts-bundle.css styles.css; do
294
+ status="$(http_request "/$asset" 2>/dev/null || printf '000')"
295
+ if [[ "$status" == "200" && -s "$TEMP_DIR/body" ]]; then
296
+ pass "$asset is served and nonempty"
297
+ else
298
+ fail "$asset is unavailable or empty"
299
+ fi
300
+ [[ "$(header_value 'Cache-Control')" == *no-store* ]] && pass "$asset disables browser caching" || fail "$asset permits browser caching"
301
+ done
302
+
303
+ for path in /index.php /__templates/super-document /__includes/functions.php /__data/example.json /docs/__data/install.json; do
304
+ status="$(http_request "$path" 2>/dev/null || printf '000')"
305
+ [[ "$status" == "404" ]] && pass "$path is protected" || fail "$path returned HTTP $status instead of 404"
306
+ done
307
+ status="$(http_request '/.user.ini' 2>/dev/null || printf '000')"
308
+ [[ "$status" == "403" || "$status" == "404" ]] && pass 'Dotfiles are protected' || fail "Dotfile returned HTTP $status"
309
+
310
+ missing_path="/missing-$RANDOM"
311
+ missing_uri="$missing_path?view=full"
312
+ status="$(http_request "$missing_uri" 2>/dev/null || printf '000')"
313
+ if [[ "$status" == "404" ]] && grep -Fq 'Create and open session' "$TEMP_DIR/body"; then
314
+ pass 'Missing routes render the interactive PHP 404 document'
315
+ else
316
+ fail 'Missing route did not render the interactive PHP 404 document'
317
+ fi
318
+ grep -Fq "name=\"requested_uri\" value=\"$missing_uri\"" "$TEMP_DIR/body" && pass 'Interactive 404 preserves the requested page URI' || fail 'Interactive 404 lost the requested page URI'
319
+ if grep -Fq 'name="prompt" type="text"' "$TEMP_DIR/body" && grep -Fq 'target="nestbox-opencode"' "$TEMP_DIR/body"; then
320
+ pass 'Interactive 404 uses a one-line prompt and the shared OpenCode window'
321
+ else
322
+ fail 'Interactive 404 prompt form has the wrong behavior'
323
+ fi
324
+ if grep -Fq 'nestbox-toolbar' "$TEMP_DIR/body" && grep -Fq 'Open Chat' "$TEMP_DIR/body" && ! grep -Fq 'Continue in OpenCode' "$TEMP_DIR/body"; then
325
+ pass 'Shared header opens the common OpenCode chat'
326
+ else
327
+ fail 'Shared header contains page-specific session controls'
328
+ fi
329
+ status="$(http_request '/404' '' 'POST' 2>/dev/null || printf '000')"
330
+ [[ "$status" == "422" ]] && pass 'Interactive 404 rejects invalid requests' || fail "Interactive 404 accepted an invalid request with HTTP $status"
331
+
332
+ nginx_id="$(container_id nginx)"
333
+ publisher_binding="$(docker inspect --format '{{json (index .NetworkSettings.Ports "8080/tcp")}}' "$nginx_id" 2>/dev/null || true)"
334
+ if [[ -z "$publisher_binding" || "$publisher_binding" == "null" ]]; then
335
+ pass 'Internal publisher port is not published on the host'
336
+ else
337
+ fail 'Internal publisher port is published on the host'
338
+ fi
339
+ status="$(http_request '/_nestbox/publish?topics=nestbox-test:public' '' 'POST' 2>/dev/null || printf '000')"
340
+ [[ "$status" == "404" ]] && pass 'Publisher endpoint is unavailable on the public port' || fail "Public publisher endpoint returned HTTP $status"
341
+
342
+ if ((INDEX_PRESENT)); then
343
+ if compose exec -T php-fpm php -l /home/code/index.php >/dev/null 2>&1; then
344
+ pass 'Installed super-document passes PHP lint'
345
+ else
346
+ fail 'Installed super-document failed PHP lint'
347
+ fi
348
+ else
349
+ pass 'Fresh page tree has no index.php to lint'
350
+ fi
351
+ if compose exec -T php-fpm php -l /home/code/404.php >/dev/null 2>&1; then
352
+ pass 'Interactive 404 super-document passes PHP lint'
353
+ else
354
+ fail 'Interactive 404 super-document failed PHP lint'
355
+ fi
356
+ context_code='require "/home/code/__includes/functions.php"; $context = nestbox_page_context("/docs/install?tab=all"); exit($context["statePath"] === "/home/code/docs/__data/install.json" && $context["agentDirectory"] === "/nestbox/home/code/docs" ? 0 : 1);'
357
+ if compose exec -T php-fpm php -r "$context_code" >/dev/null 2>&1; then
358
+ pass 'Nested pages map to directory-local data and agent scope'
359
+ else
360
+ fail 'Nested page data or agent context is incorrect'
361
+ fi
362
+ if compose exec -T php-fpm php /sources/php/cli.php __templates/super-document >/dev/null 2>&1; then
363
+ fail 'Protected super-document executed through the CLI router'
364
+ else
365
+ pass 'CLI router rejects protected super-documents'
366
+ fi
367
+
368
+ opencode_health_code='require "/home/code/__includes/functions.php"; $health = nestbox_opencode_request("GET", "/global/health", [], "/workspace"); exit(($health["healthy"] ?? false) ? 0 : 1);'
369
+ if compose exec -T php-fpm php -r "$opencode_health_code" >/dev/null 2>&1; then
370
+ pass 'PHP reaches the authenticated internal OpenCode API'
371
+ else
372
+ fail 'PHP cannot reach the authenticated internal OpenCode API'
373
+ fi
374
+
375
+ status="$(http_request '/Agent' "localhost:$WEB_PORT" 2>/dev/null || printf '000')"
376
+ location="$(header_value 'Location')"
377
+ if [[ "$status" == "302" && "$location" == "http://agent.localhost:$WEB_PORT/" ]]; then
378
+ pass '/Agent redirects to the same-port OpenCode origin'
379
+ else
380
+ fail "/Agent redirect was HTTP $status with Location $location"
381
+ fi
382
+
383
+ status="$(http_request '/doc' "agent.localhost:$WEB_PORT" 2>/dev/null || printf '000')"
384
+ if [[ "$status" == "401" || "$status" =~ ^[23][0-9][0-9]$ ]]; then
385
+ pass 'OpenCode host routing responds'
386
+ else
387
+ fail "OpenCode host routing returned HTTP $status"
388
+ fi
389
+
390
+ channel="nestbox-test:$(date +%s)-$$"
391
+ token="event-$(date +%s)-$$-$RANDOM"
392
+ sse_file="$TEMP_DIR/events"
393
+ curl --silent --show-error --noproxy '*' --no-buffer --max-time "$TIMEOUT" \
394
+ --header 'Accept: text/event-stream' \
395
+ "$BASE_URL/_nestbox/events?topics=nestbox-test:*" >"$sse_file" 2>"$TEMP_DIR/events-error" &
396
+ SSE_PID=$!
397
+ deadline=$((SECONDS + TIMEOUT))
398
+ while ((SECONDS < deadline)); do
399
+ grep -Fq ': hi' "$sse_file" 2>/dev/null && break
400
+ sleep 1
401
+ done
402
+ if grep -Fq ': hi' "$sse_file" 2>/dev/null; then
403
+ pass 'EventSource subscriber is connected'
404
+ else
405
+ fail 'EventSource subscriber did not connect'
406
+ fi
407
+
408
+ php_code='require "/home/code/__includes/functions.php"; exit(nestbox_bus_publish(getenv("TEST_TOPIC"), "changed", ["token" => getenv("TEST_TOKEN")]) ? 0 : 1);'
409
+ if compose exec -T -e "TEST_TOPIC=$channel" -e "TEST_TOKEN=$token" php-fpm php -r "$php_code" >/dev/null 2>&1; then
410
+ pass 'PHP publishes through the internal Nchan endpoint'
411
+ else
412
+ fail 'PHP failed to publish through the internal Nchan endpoint'
413
+ fi
414
+
415
+ deadline=$((SECONDS + TIMEOUT))
416
+ while ((SECONDS < deadline)); do
417
+ grep -Fq "$token" "$sse_file" 2>/dev/null && break
418
+ sleep 1
419
+ done
420
+ if grep -Fq 'event: changed' "$sse_file" && grep -Fq "$token" "$sse_file" && grep -Fq "$channel" "$sse_file"; then
421
+ pass 'EventSource receives the wildcard Nchan event with its payload'
422
+ else
423
+ fail 'EventSource did not receive the expected Nchan event'
424
+ fi
425
+
426
+ for entry in "nginx:$NGINX_RESTARTS" "php-fpm:$PHP_RESTARTS" "opencode:$OPENCODE_RESTARTS"; do
427
+ service="${entry%%:*}"
428
+ before="${entry#*:}"
429
+ after="$(restart_count "$service" 2>/dev/null || printf '')"
430
+ if [[ -n "$before" && "$before" == "$after" ]] && container_running "$service"; then
431
+ pass "$service remained stable during the test"
432
+ else
433
+ fail "$service restarted or stopped during the test"
434
+ fi
435
+ done
436
+
437
+ printf '\n%d passed, %d failed, %d skipped\n' "$PASSES" "$FAILURES" "$SKIPS"
438
+ ((FAILURES == 0))