diff --git a/.github/actions/setup-godot/action.yml b/.github/actions/setup-godot/action.yml index b12f973..84438ce 100644 --- a/.github/actions/setup-godot/action.yml +++ b/.github/actions/setup-godot/action.yml @@ -1,55 +1,79 @@ -name: 'Setup Godot' -description: 'Install Godot binary with caching and optionally install export templates' +name: Setup Godot +description: Install a checksum-verified Godot Linux binary inputs: godot-version: - description: 'Godot version to install (e.g., 4.4.1)' + description: Godot version to install + required: true + godot-linux-x86-64-sha256: + description: SHA-256 of the immutable Linux x86_64 release ZIP required: true install-templates: - description: 'Whether to install export templates' required: false default: 'false' + godot-templates-sha256: + required: false + default: '' runs: - using: 'composite' + using: composite steps: - - name: Cache Godot binary - id: cache-godot + - name: Cache verified Godot archive uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: - path: /usr/local/bin/godot - key: godot-${{ inputs.godot-version }}-${{ runner.os }} + path: ~/.cache/aviorstudio/godot/${{ inputs.godot-version }} + key: godot-${{ inputs.godot-version }}-linux-x86-64-${{ inputs.godot-linux-x86-64-sha256 }} - - name: Install Godot - if: steps.cache-godot.outputs.cache-hit != 'true' + - name: Download, verify, and install Godot shell: bash + env: + GODOT_VERSION: ${{ inputs.godot-version }} + GODOT_SHA256: ${{ inputs.godot-linux-x86-64-sha256 }} run: | - wget -q https://github.com/godotengine/godot/releases/download/${{ inputs.godot-version }}-stable/Godot_v${{ inputs.godot-version }}-stable_linux.x86_64.zip - unzip -q Godot_v${{ inputs.godot-version }}-stable_linux.x86_64.zip - chmod +x Godot_v${{ inputs.godot-version }}-stable_linux.x86_64 - sudo mv Godot_v${{ inputs.godot-version }}-stable_linux.x86_64 /usr/local/bin/godot + set -euo pipefail + install_dir="$HOME/.cache/aviorstudio/godot/$GODOT_VERSION" + archive="$install_dir/Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip" + binary="$install_dir/Godot_v${GODOT_VERSION}-stable_linux.x86_64" + mkdir -p "$install_dir" + if [ ! -f "$archive" ]; then + curl --fail --location --retry 3 --max-time 180 --output "$archive" \ + "https://github.com/godotengine/godot-builds/releases/download/${GODOT_VERSION}-stable/$(basename "$archive")" + fi + printf '%s %s\n' "$GODOT_SHA256" "$archive" | sha256sum --check --strict + if [ ! -x "$binary" ]; then + unzip -q -o "$archive" -d "$install_dir" + chmod +x "$binary" + fi + ln -sf "$binary" "$install_dir/godot" + echo "$install_dir" >> "$GITHUB_PATH" - name: Verify Godot installation - shell: bash - run: godot --version - - - name: Cache Godot export templates - if: inputs.install-templates == 'true' - id: cache-godot-templates - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: ~/.local/share/godot/export_templates/${{ inputs.godot-version }}.stable - key: godot-templates-${{ inputs.godot-version }}-${{ runner.os }} - - - name: Install Godot export templates - if: inputs.install-templates == 'true' && steps.cache-godot-templates.outputs.cache-hit != 'true' shell: bash run: | - mkdir -p ~/.local/share/godot/export_templates/${{ inputs.godot-version }}.stable - wget -q https://github.com/godotengine/godot/releases/download/${{ inputs.godot-version }}-stable/Godot_v${{ inputs.godot-version }}-stable_export_templates.tpz - unzip -q Godot_v${{ inputs.godot-version }}-stable_export_templates.tpz - mv templates/* ~/.local/share/godot/export_templates/${{ inputs.godot-version }}.stable/ + set -euo pipefail + case "$(godot --version)" in + 4.7.2.stable.*) godot --version ;; + *) echo 'Unexpected Godot version' >&2; exit 1 ;; + esac - - name: Verify Godot templates + - name: Install checksum-verified web templates if: inputs.install-templates == 'true' shell: bash - run: ls -la ~/.local/share/godot/export_templates/${{ inputs.godot-version }}.stable/ + env: + GODOT_VERSION: ${{ inputs.godot-version }} + TEMPLATES_SHA256: ${{ inputs.godot-templates-sha256 }} + run: | + set -euo pipefail + test -n "$TEMPLATES_SHA256" + cache_dir="$HOME/.cache/aviorstudio/godot/$GODOT_VERSION" + archive="$cache_dir/Godot_v${GODOT_VERSION}-stable_export_templates.tpz" + target="$HOME/.local/share/godot/export_templates/${GODOT_VERSION}.stable" + if [ ! -f "$archive" ]; then + curl --fail --location --retry 3 --max-time 180 --output "$archive" \ + "https://github.com/godotengine/godot-builds/releases/download/${GODOT_VERSION}-stable/$(basename "$archive")" + fi + printf '%s %s\n' "$TEMPLATES_SHA256" "$archive" | sha256sum --check --strict + temporary="$(mktemp -d)" + trap 'rm -rf "$temporary"' EXIT + unzip -q "$archive" 'templates/web_nothreads_release.zip' -d "$temporary" + mkdir -p "$target" + cp "$temporary/templates/web_nothreads_release.zip" "$target/" diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml index 89721f9..ade2ad9 100644 --- a/.github/actions/test/action.yml +++ b/.github/actions/test/action.yml @@ -29,19 +29,12 @@ runs: test -f addon/plugin.cfg - name: Install verified Godot test binary - shell: bash - run: | - set -euo pipefail - archive="$RUNNER_TEMP/gd-env-godot.zip" - directory="$RUNNER_TEMP/gd-env-godot" - curl --fail --location --retry 3 --max-time 180 \ - https://github.com/godotengine/godot-builds/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip \ - --output "$archive" - printf '%s %s\n' '9aa00f7a605200940bce3027a567b782f49bd8e940dd06ae9e987bd65aee1b1467edd56ed84fcdcbdd44354bf613bdbb4e5d2913e925850368e150c59ed54c65' "$archive" | sha512sum --check - mkdir -p "$directory" - unzip -o "$archive" Godot_v4.7.2-stable_linux.x86_64 -d "$directory" - chmod +x "$directory/Godot_v4.7.2-stable_linux.x86_64" - echo "GODOT_BIN=$directory/Godot_v4.7.2-stable_linux.x86_64" >> "$GITHUB_ENV" + uses: ./.github/actions/setup-godot + with: + godot-version: '4.7.2' + godot-linux-x86-64-sha256: 'cadd3204e728a35d3f13adb7fd0d7902636b79f6b95c40c265eb73b6c35329e4' + install-templates: 'true' + godot-templates-sha256: 'f298490b8d44d934be425a5a65a51bf15f422428b229a06a6e11d9ffea248011' # No `if: hashFiles(...)` guard. This step used to skip itself when # tests/test.sh was absent, which is indistinguishable from the script being @@ -50,3 +43,11 @@ runs: - name: Godot tests shell: bash run: ./tests/test.sh + + - name: Build and test exact release package + shell: bash + run: | + ./scripts/package_addon.sh + ./scripts/verify_package_checksum.sh + ./tests/package_test.sh + ./tests/web_package_test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c323cbc..27d1c32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,17 @@ jobs: # comment so the version is still readable. A tag is a moving reference: # whoever can move it can run code in this job. - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Test uses: ./.github/actions/test + + - name: Upload exact tested package evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: tested-package-${{ github.event.pull_request.head.sha || github.sha }} + path: | + dist/@aviorstudio_gd-env.zip + dist/@aviorstudio_gd-env.zip.sha256 + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0ef0df..9d3c249 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,22 +7,20 @@ on: description: Version bump required: true type: choice - options: - - patch - - minor - - major + options: [patch, minor, major] permissions: - contents: write + contents: read concurrency: release-${{ github.repository }} jobs: - release: + test: runs-on: ubuntu-latest - # Bounded, so a step that hangs fails here rather than sitting until the - # runner's own timeout hours later. timeout-minutes: 20 + outputs: + version: ${{ steps.release.outputs.version }} + tag: ${{ steps.release.outputs.tag }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: @@ -34,72 +32,84 @@ jobs: BUMP: ${{ inputs.bump }} run: | set -euo pipefail - if [ "$GITHUB_REF" != "refs/heads/main" ]; then - echo 'Run releases from the main branch.' >&2 - exit 1 - fi + test "$GITHUB_REF" = refs/heads/main git fetch --tags --force latest="$(git tag --list 'v[0-9]*' | sed -E 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)" if [ -z "$latest" ]; then - version="0.0.1" + version=0.0.1 else - IFS=. read -r major minor patch <<< "$latest" + IFS=. read -r major minor patch <<<"$latest" case "$BUMP" in major) major=$((major + 1)); minor=0; patch=0 ;; minor) minor=$((minor + 1)); patch=0 ;; patch) patch=$((patch + 1)) ;; *) echo "Unsupported bump: $BUMP" >&2; exit 1 ;; esac - version="${major}.${minor}.${patch}" - fi - tag="v${version}" - if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then - echo "Tag already exists: $tag" >&2 - exit 1 + version="$major.$minor.$patch" fi + tag="v$version" + ! git rev-parse -q --verify "refs/tags/$tag" >/dev/null plugin_version="$(sed -n -E 's/^version="([^"]+)"/\1/p' addon/plugin.cfg | head -n 1)" - if [ "$plugin_version" != "$version" ]; then - echo "addon/plugin.cfg version is $plugin_version, but the next $BUMP release is $version." >&2 - echo "Update addon/plugin.cfg to version=\"$version\", commit it, then rerun this workflow." >&2 - exit 1 - fi - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=$tag" >> "$GITHUB_OUTPUT" + test "$plugin_version" = "$version" + echo "version=$version" >>"$GITHUB_OUTPUT" + echo "tag=$tag" >>"$GITHUB_OUTPUT" - # The same checks CI runs, from the same definition. This workflow used - # to package and publish without running any of them -- the only thing - # between a broken commit and the GDAM registry was whether somebody had - # looked at CI. Running them here against this exact commit is the point: - # CI passing on this SHA earlier is a claim about that run. - - name: Test + - name: Test exact release commit and package uses: ./.github/actions/test - - name: Package addon - run: | - set -euo pipefail - test -f addon/plugin.cfg - repo_owner="${GITHUB_REPOSITORY%%/*}" - addon_dir="@${repo_owner}_${GITHUB_REPOSITORY#*/}" - package_root="dist/${addon_dir}" - mkdir -p "$package_root" - cp addon/plugin.cfg addon/plugin.gd "$package_root/" - cp addon/*.uid "$package_root/" 2>/dev/null || true - if [ -f addon/autoload.gd ]; then cp addon/autoload.gd "$package_root/"; fi - if [ -d addon/src ]; then cp -R addon/src "$package_root/"; fi - (cd "$package_root" && zip -r "../${addon_dir}.zip" .) + - name: Upload exact tested package + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-package-${{ github.sha }} + path: | + dist/@aviorstudio_gd-env.zip + dist/@aviorstudio_gd-env.zip.sha256 + if-no-files-found: error + + publish: + needs: test + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: release + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Download exact tested package + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-package-${{ github.sha }} + path: dist + + - name: Verify downloaded package identity + run: ./scripts/verify_package_checksum.sh - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.release.outputs.tag }} - run: gh release create "$TAG" dist/*.zip --target "$GITHUB_SHA" --title "$TAG" --notes "Release $TAG" + TAG: ${{ needs.test.outputs.tag }} + run: gh release create "$TAG" 'dist/@aviorstudio_gd-env.zip' --target "$GITHUB_SHA" --title "$TAG" --notes "Release $TAG" - - name: Install GDAM - uses: aviorstudio/gdam-actions/install@v0.0.2 + - name: Install checksum-verified GDAM v0.0.8 + id: install-gdam + uses: aviorstudio/gdam-actions/install@d735444eb470194585def44521d5d91df2260e63 # v0.0.2 + with: + version: 'v0.0.8' + + - name: Verify GDAM version + env: + INSTALLED_VERSION: ${{ steps.install-gdam.outputs.version }} + run: | + case "$INSTALLED_VERSION" in + *0.0.8*) printf '%s\n' "$INSTALLED_VERSION" ;; + *) echo "Unexpected GDAM version: $INSTALLED_VERSION" >&2; exit 1 ;; + esac - - name: Publish to GDAM - uses: aviorstudio/gdam-actions/publish@v0.0.2 + - name: Publish exact GitHub asset to GDAM + uses: aviorstudio/gdam-actions/publish@d735444eb470194585def44521d5d91df2260e63 # v0.0.2 with: - version: ${{ steps.release.outputs.version }} - tag: ${{ steps.release.outputs.tag }} + tag: ${{ needs.test.outputs.tag }} + addon: '@aviorstudio/gd-env' + asset: '@aviorstudio_gd-env.zip' secret-key: ${{ secrets.GDAM_SECRET_KEY }} diff --git a/.gitignore b/.gitignore index e43b0f9..f461db3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .DS_Store +dist/ +.playwright-cli/ diff --git a/README.md b/README.md index 8043f5f..811f6b4 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,17 @@ its response; native threaded blocking reads can otherwise stall cancellation. The loopback regression suite exercises those paths, time scale zero, successful completion, disabled deadlines and owner cleanup. +HTTP configuration responses are limited to 1 MiB by default. Pass the optional +final `max_body_bytes` argument to select a positive limit up to 16 MiB. The +limit is assigned to Godot's `HTTPRequest.body_size_limit` before starting the +request, so declared, chunked, and decompressed bodies are bounded during +transfer rather than checked only after accumulation. `LoadResult.error_code` +provides typed timeout, body-size, transport, HTTP-status, and JSON parse +outcomes; `body_too_large` results retain no bytes beyond the selected cap. +The 1 MiB default, 16 MiB ceiling, and existing 10-second default deadline were +approved in the decision record on +[fieldsofrevik#140](https://github.com/aviorstudio/fieldsofrevik/issues/140#issuecomment-5651934845). + ## Repository Layout - `addon/`: Godot plugin source packaged for GDAM and manual installation. @@ -78,7 +89,16 @@ Run locally with: ./tests/test.sh ``` -CI and releases run the same bounded script with a checksum-verified Godot 4.7.2 binary. Runtime errors and missing PASS markers fail the suite, including when the engine exits zero. +**Correction — [fieldsofrevik#143](https://github.com/aviorstudio/fieldsofrevik/issues/143):** +The earlier statement said CI and releases ran the same bounded script, but it +did not establish that the assembled ZIP was the package exercised by editor +lifecycle tests, and publication still used mutable action tags. CI and release +now run the same behavior and exact-package lifecycle gates with checksum- +verified Godot 4.7.2. Release transfers the tested ZIP and relative checksum to +an isolated publication job without rebuilding it, pins executable actions by +full commit SHA, and explicitly installs checksum-verified GDAM v0.0.8. Runtime +errors and missing reachable PASS markers fail the suite even when Godot exits +zero. ## License diff --git a/addon/plugin.cfg b/addon/plugin.cfg index 88ece14..ec9fca2 100644 --- a/addon/plugin.cfg +++ b/addon/plugin.cfg @@ -2,5 +2,5 @@ name="GD Env" description="Environment/config loading helpers (dotenv + JSON + OS env)." author="Avior Studio" -version="0.0.2" +version="0.0.3" script="plugin.gd" diff --git a/addon/plugin.gd b/addon/plugin.gd index bf6b9cf..1540a0d 100644 --- a/addon/plugin.gd +++ b/addon/plugin.gd @@ -3,20 +3,35 @@ extends EditorPlugin const AUTOLOAD_NAME := "GdEnv" const AUTOLOAD_SCRIPT := "autoload.gd" +const OWNERSHIP_SETTING := "gd_env/plugin_owns_autoload" -var _added_autoload: bool = false - -func _enter_tree() -> void: +func _enable_plugin() -> void: var key: String = "autoload/" + AUTOLOAD_NAME if ProjectSettings.has_setting(key): - _added_autoload = false return + add_autoload_singleton(AUTOLOAD_NAME, _autoload_path()) + ProjectSettings.set_setting(OWNERSHIP_SETTING, true) + ProjectSettings.save() - var base_dir: String = str(get_script().resource_path).get_base_dir() - add_autoload_singleton(AUTOLOAD_NAME, base_dir.path_join(AUTOLOAD_SCRIPT)) - _added_autoload = true - -func _exit_tree() -> void: - if _added_autoload: +func _disable_plugin() -> void: + if not bool(ProjectSettings.get_setting(OWNERSHIP_SETTING, false)): + return + if _autoload_setting_matches_plugin(): remove_autoload_singleton(AUTOLOAD_NAME) + ProjectSettings.clear(OWNERSHIP_SETTING) + ProjectSettings.save() +func _autoload_path() -> String: + var base_dir: String = str(get_script().resource_path).get_base_dir() + return base_dir.path_join(AUTOLOAD_SCRIPT) + +func _autoload_setting_matches_plugin() -> bool: + var key: String = "autoload/" + AUTOLOAD_NAME + if not ProjectSettings.has_setting(key): + return false + var configured_path := str(ProjectSettings.get_setting(key)).trim_prefix("*") + if configured_path.begins_with("uid://"): + var uid := ResourceUID.text_to_id(configured_path) + if ResourceUID.has_id(uid): + configured_path = ResourceUID.get_id_path(uid) + return configured_path == _autoload_path() diff --git a/addon/src/env_json_module.gd b/addon/src/env_json_module.gd index b17a324..040eefb 100644 --- a/addon/src/env_json_module.gd +++ b/addon/src/env_json_module.gd @@ -2,6 +2,24 @@ class_name EnvJsonModule extends RefCounted +const DEFAULT_MAX_BODY_BYTES := 1024 * 1024 +const MAX_CONFIGURABLE_BODY_BYTES := 16 * 1024 * 1024 + +enum ErrorCode { + NONE, + MISSING_OWNER, + EMPTY_URL, + INVALID_TIMEOUT, + INVALID_MAX_BODY_BYTES, + REQUEST_START_FAILED, + REQUEST_FAILED, + TIMEOUT, + BODY_TOO_LARGE, + HTTP_STATUS, + JSON_PARSE, + EXPECTED_DICTIONARY, +} + ## Standardized JSON load result payload. class LoadResult extends RefCounted: var success: bool @@ -11,19 +29,24 @@ class LoadResult extends RefCounted: ## HTTPRequest.Result, or -1 when no HTTP completion occurred. var request_result: int = -1 var error_message: String + var error_code: ErrorCode = ErrorCode.NONE + var received_bytes: int = -1 + var declared_bytes: int = -1 func _init( success: bool = false, source: String = "", status_code: int = 0, data: Dictionary[String, Variant] = {}, - error_message: String = "" + error_message: String = "", + error_code: ErrorCode = ErrorCode.NONE ) -> void: self.success = success self.source = source self.status_code = status_code self.data = data self.error_message = error_message + self.error_code = error_code ## Owns a wall-clock deadline without charging the frame before request start. ## HTTPRequest's built-in Timer can consume a stale process step on startup. @@ -76,19 +99,23 @@ static func load_dict_from_http( callback: Callable, timeout_s: float = 10.0, cache_bust: bool = true, - cache_bust_key: String = "v" + cache_bust_key: String = "v", + max_body_bytes: int = DEFAULT_MAX_BODY_BYTES ) -> void: if not callback.is_valid(): return if not owner: - callback.call(LoadResult.new(false, url, 0, {}, "missing_owner")) + callback.call(LoadResult.new(false, url, 0, {}, "missing_owner", ErrorCode.MISSING_OWNER)) return if url.is_empty(): - callback.call(LoadResult.new(false, url, 0, {}, "empty_url")) + callback.call(LoadResult.new(false, url, 0, {}, "empty_url", ErrorCode.EMPTY_URL)) return if not is_finite(timeout_s) or timeout_s < 0.0: - callback.call(LoadResult.new(false, url, 0, {}, "invalid_timeout")) + callback.call(LoadResult.new(false, url, 0, {}, "invalid_timeout", ErrorCode.INVALID_TIMEOUT)) + return + if max_body_bytes <= 0 or max_body_bytes > MAX_CONFIGURABLE_BODY_BYTES: + callback.call(LoadResult.new(false, url, 0, {}, "invalid_max_body_bytes", ErrorCode.INVALID_MAX_BODY_BYTES)) return var request_node := DeadlineHttpRequest.new() @@ -96,20 +123,31 @@ static func load_dict_from_http( # Configuration requests use nonblocking polling on every platform. request_node.use_threads = false request_node.timeout = 0.0 + request_node.body_size_limit = max_body_bytes owner.add_child(request_node) var final_url: String = _resolve_web_relative_url(url) if cache_bust: final_url = _with_query_param(final_url, cache_bust_key, str(Time.get_unix_time_from_system())) - var handler: Callable = func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + var handler: Callable = func(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void: request_node.disarm_deadline() var out := LoadResult.new(false, final_url, response_code, {}, "") out.request_result = result - - if result != HTTPRequest.RESULT_SUCCESS: + out.received_bytes = body.size() + out.declared_bytes = _content_length(headers) + + if result == HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED: + out.error_code = ErrorCode.BODY_TOO_LARGE + out.error_message = "body_too_large" + elif result == HTTPRequest.RESULT_TIMEOUT: + out.error_code = ErrorCode.TIMEOUT + out.error_message = "request_failed" + elif result != HTTPRequest.RESULT_SUCCESS: + out.error_code = ErrorCode.REQUEST_FAILED out.error_message = "request_failed" elif response_code < 200 or response_code >= 300: + out.error_code = ErrorCode.HTTP_STATUS out.error_message = "http_" + str(response_code) else: var body_text: String = body.get_string_from_utf8() @@ -130,21 +168,29 @@ static func load_dict_from_http( request_node.request_completed.disconnect(handler) request_node.disarm_deadline() request_node.queue_free() - callback.call(LoadResult.new(false, final_url, 0, {}, "request_error_" + str(err))) + callback.call(LoadResult.new(false, final_url, 0, {}, "request_error_" + str(err), ErrorCode.REQUEST_START_FAILED)) ## Parses raw JSON text into a typed dictionary load result. static func parse_json_dict(json_text: String) -> LoadResult: var json := JSON.new() var parse_result: int = json.parse(json_text) if parse_result != OK: - return LoadResult.new(false, "", 0, {}, "parse_error: " + json.get_error_message()) + return LoadResult.new(false, "", 0, {}, "parse_error: " + json.get_error_message(), ErrorCode.JSON_PARSE) var payload: Variant = json.data if not (payload is Dictionary): - return LoadResult.new(false, "", 0, {}, "parse_error: expected_dictionary") + return LoadResult.new(false, "", 0, {}, "parse_error: expected_dictionary", ErrorCode.EXPECTED_DICTIONARY) return LoadResult.new(true, "", 0, normalize_string_keys(payload), "") +static func _content_length(headers: PackedStringArray) -> int: + for header: String in headers: + if header.to_lower().begins_with("content-length:"): + var value := header.get_slice(":", 1).strip_edges() + if value.is_valid_int(): + return value.to_int() + return -1 + ## Converts dictionary keys to strings for stable typed access. static func normalize_string_keys(raw: Dictionary) -> Dictionary[String, Variant]: var normalized: Dictionary[String, Variant] = {} diff --git a/evidence/web-http.png b/evidence/web-http.png new file mode 100644 index 0000000..a21b568 Binary files /dev/null and b/evidence/web-http.png differ diff --git a/package/addon_manifest.txt b/package/addon_manifest.txt new file mode 100644 index 0000000..cf9c9a5 --- /dev/null +++ b/package/addon_manifest.txt @@ -0,0 +1,11 @@ +autoload.gd +autoload.gd.uid +plugin.cfg +plugin.gd +plugin.gd.uid +src/dotenv_module.gd +src/dotenv_module.gd.uid +src/env_json_module.gd +src/env_json_module.gd.uid +src/env_var_module.gd +src/env_var_module.gd.uid diff --git a/scripts/package_addon.sh b/scripts/package_addon.sh new file mode 100755 index 0000000..890b7bd --- /dev/null +++ b/scripts/package_addon.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +MANIFEST="$ROOT_DIR/package/addon_manifest.txt" +DIST_DIR=${1:-"$ROOT_DIR/dist"} +ARCHIVE="$DIST_DIR/@aviorstudio_gd-env.zip" +STAGE=$(mktemp -d) +actual=$(mktemp) +expected=$(mktemp) +trap 'rm -rf "$STAGE" "$actual" "$expected"' EXIT + +test -s "$MANIFEST" +mkdir -p "$DIST_DIR" +rm -f "$ARCHIVE" "$ARCHIVE.sha256" + +while IFS= read -r relative || [ -n "$relative" ]; do + [ -n "$relative" ] || continue + source_file="$ROOT_DIR/addon/$relative" + if [ -L "$source_file" ]; then + echo "Package manifest rejects symlink: addon/$relative" >&2 + exit 1 + fi + if [ ! -f "$source_file" ]; then + echo "Package manifest entry is missing: addon/$relative" >&2 + exit 1 + fi + mkdir -p "$STAGE/$(dirname "$relative")" + cp "$source_file" "$STAGE/$relative" + touch -t 198001010000 "$STAGE/$relative" +done <"$MANIFEST" + +(cd "$ROOT_DIR/addon" && find . -type l -print -quit) | grep -q . && { + echo "Addon tree contains a symlink" >&2 + exit 1 +} +(cd "$ROOT_DIR/addon" && find . -type f -printf '%P\n' | LC_ALL=C sort) >"$actual" +LC_ALL=C sort "$MANIFEST" >"$expected" +if ! diff -u "$expected" "$actual"; then + echo "Addon tree and closed package manifest differ" >&2 + exit 1 +fi + +(cd "$STAGE" && zip -X -q "$ARCHIVE" -@ <"$MANIFEST") +(cd "$DIST_DIR" && sha256sum '@aviorstudio_gd-env.zip' >'@aviorstudio_gd-env.zip.sha256') +(cd "$DIST_DIR" && sha256sum --check --strict '@aviorstudio_gd-env.zip.sha256') diff --git a/scripts/verify_package_archive.sh b/scripts/verify_package_archive.sh new file mode 100755 index 0000000..cda19ee --- /dev/null +++ b/scripts/verify_package_archive.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: $0 ARCHIVE EXPECTED_MANIFEST" >&2 + exit 2 +fi + +archive=$1 +manifest=$2 +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +test -f "$archive" +test -s "$manifest" +unzip -Z1 "$archive" | LC_ALL=C sort >"$work/actual" +LC_ALL=C sort "$manifest" >"$work/expected" +diff -u "$work/expected" "$work/actual" +if zipinfo -l "$archive" | grep -Eq '^l'; then + echo "release ZIP contains a symlink" >&2 + exit 1 +fi +echo "REACHED gd-env package_archive assertions=3" diff --git a/scripts/verify_package_checksum.sh b/scripts/verify_package_checksum.sh new file mode 100755 index 0000000..5346420 --- /dev/null +++ b/scripts/verify_package_checksum.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd) +DIST_DIR=${1:-"$ROOT_DIR/dist"} + +(cd "$DIST_DIR" && sha256sum --check --strict '@aviorstudio_gd-env.zip.sha256') diff --git a/tests/dotenv_module_test.gd b/tests/dotenv_module_test.gd index 0d41c48..14c7253 100644 --- a/tests/dotenv_module_test.gd +++ b/tests/dotenv_module_test.gd @@ -8,6 +8,7 @@ func _initialize() -> void: _test_load_file_reads_env_data(failures) if failures.is_empty(): + print("REACHED gd-env dotenv_module_test assertions=1") print("PASS gd-env dotenv_module_test") quit(0) return @@ -57,4 +58,4 @@ func _test_load_file_reads_env_data(failures: Array[String]) -> void: var loaded: Dictionary[String, String] = DotenvModule.load_file(path) if loaded.get("FILE_VALUE", "") != "from_file": - failures.append("Expected load_file to parse key from file") \ No newline at end of file + failures.append("Expected load_file to parse key from file") diff --git a/tests/env_json_module_test.gd b/tests/env_json_module_test.gd index bea1447..d463a27 100644 --- a/tests/env_json_module_test.gd +++ b/tests/env_json_module_test.gd @@ -13,6 +13,7 @@ func _initialize() -> void: _test_load_dict_from_http_missing_owner(failures) if failures.is_empty(): + print("REACHED gd-env env_json_module_test assertions=1") print("PASS gd-env env_json_module_test") quit(0) return diff --git a/tests/env_var_module_test.gd b/tests/env_var_module_test.gd index 9c94921..7ec013e 100644 --- a/tests/env_var_module_test.gd +++ b/tests/env_var_module_test.gd @@ -9,6 +9,7 @@ func _initialize() -> void: _test_get_required_result(failures) if failures.is_empty(): + print("REACHED gd-env env_var_module_test assertions=1") print("PASS gd-env env_var_module_test") quit(0) return diff --git a/tests/gate/fixtures/hang.gd b/tests/gate/fixtures/hang.gd new file mode 100644 index 0000000..bbcadc0 --- /dev/null +++ b/tests/gate/fixtures/hang.gd @@ -0,0 +1,4 @@ +extends SceneTree + +func _initialize() -> void: + pass diff --git a/tests/gate/fixtures/overwritten_exit.gd b/tests/gate/fixtures/overwritten_exit.gd new file mode 100644 index 0000000..ca9eeb5 --- /dev/null +++ b/tests/gate/fixtures/overwritten_exit.gd @@ -0,0 +1,6 @@ +extends SceneTree + +func _initialize() -> void: + push_error("deliberate gate control: assertion failure before overwritten quit") + quit(1) + quit(0) diff --git a/tests/gate/fixtures/parse_failure.gd b/tests/gate/fixtures/parse_failure.gd new file mode 100644 index 0000000..3403fe2 --- /dev/null +++ b/tests/gate/fixtures/parse_failure.gd @@ -0,0 +1,4 @@ +extends SceneTree + +func _initialize() -> void + print("This fixture must not parse") diff --git a/tests/gate/fixtures/push_error_zero.gd b/tests/gate/fixtures/push_error_zero.gd new file mode 100644 index 0000000..fe8a74a --- /dev/null +++ b/tests/gate/fixtures/push_error_zero.gd @@ -0,0 +1,7 @@ +extends SceneTree + +func _initialize() -> void: + push_error("deliberate gate control: runtime error with zero exit") + print("REACHED gd-env push_error_zero assertions=1") + print("PASS gd-env push_error_zero") + quit(0) diff --git a/tests/gate/fixtures/unexpected_error.gd b/tests/gate/fixtures/unexpected_error.gd new file mode 100644 index 0000000..d31d92e --- /dev/null +++ b/tests/gate/fixtures/unexpected_error.gd @@ -0,0 +1,7 @@ +extends SceneTree + +func _initialize() -> void: + push_error("deliberate gate control: unexpected log error") + print("REACHED gd-env unexpected_error assertions=1") + print("PASS gd-env unexpected_error") + quit(0) diff --git a/tests/gate/fixtures/unreachable.gd b/tests/gate/fixtures/unreachable.gd new file mode 100644 index 0000000..79040b7 --- /dev/null +++ b/tests/gate/fixtures/unreachable.gd @@ -0,0 +1,5 @@ +extends SceneTree + +func _initialize() -> void: + print("PASS gd-env unreachable") + quit(0) diff --git a/tests/gate/fixtures/valid.gd b/tests/gate/fixtures/valid.gd new file mode 100644 index 0000000..9e03ec7 --- /dev/null +++ b/tests/gate/fixtures/valid.gd @@ -0,0 +1,6 @@ +extends SceneTree + +func _initialize() -> void: + print("REACHED gd-env valid assertions=1") + print("PASS gd-env valid") + quit(0) diff --git a/tests/gate/test_gate.sh b/tests/gate/test_gate.sh new file mode 100755 index 0000000..67bd295 --- /dev/null +++ b/tests/gate/test_gate.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 LOG_DIR" >&2 + exit 2 +fi + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +RUN_CASE="$SCRIPT_DIR/../run_godot_case.sh" +LOG_DIR=$1 +mkdir -p "$LOG_DIR" + +expect_rejected() { + local fixture=$1 + local case_name=$2 + local timeout_seconds=${3:-5} + if "$RUN_CASE" "$SCRIPT_DIR/fixtures/$fixture" "$case_name" "$timeout_seconds" "$LOG_DIR/$case_name.log"; then + echo "FAIL: gate accepted negative control $case_name" >&2 + return 1 + fi + echo "CONTROL_REJECTED gd-env $case_name" +} + +# A known-good control establishes that the gate itself can pass. +"$RUN_CASE" "$SCRIPT_DIR/fixtures/valid.gd" valid 5 "$LOG_DIR/valid-before.log" + +expect_rejected push_error_zero.gd push_error_zero +expect_rejected overwritten_exit.gd overwritten_exit +expect_rejected parse_failure.gd parse_failure +expect_rejected hang.gd hang 1 +expect_rejected unexpected_error.gd unexpected_error +expect_rejected unreachable.gd unreachable + +if "$RUN_CASE" "$SCRIPT_DIR/fixtures/does_not_exist.gd" missing_script 5 "$LOG_DIR/missing_script.log"; then + echo "FAIL: gate accepted missing test script" >&2 + exit 1 +fi +echo "CONTROL_REJECTED gd-env missing_script" + +# Restore the valid fixture after every negative control and prove reachability. +"$RUN_CASE" "$SCRIPT_DIR/fixtures/valid.gd" valid 5 "$LOG_DIR/valid-restored.log" +echo "REACHED gd-env gate_controls assertions=9" +echo "PASS gd-env gate_controls" diff --git a/tests/http_deadline_test.gd b/tests/http_deadline_test.gd index 312a4da..212641e 100644 --- a/tests/http_deadline_test.gd +++ b/tests/http_deadline_test.gd @@ -7,6 +7,9 @@ class HttpFixture extends Node: var peers: Array[StreamPeerTCP] = [] var respond: bool = true var body: String = '{"ready":true}' + var chunked: bool = false + var declared_length: int = -1 + var gzip: bool = false func _process(_delta: float) -> void: if server.is_connection_available(): @@ -16,8 +19,20 @@ class HttpFixture extends Node: if respond and peer.get_status() == StreamPeerTCP.STATUS_CONNECTED and peer.get_available_bytes() > 0: peer.get_data(peer.get_available_bytes()) var payload := body.to_utf8_buffer() - peer.put_data(("HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n" % payload.size()).to_utf8_buffer()) - peer.put_data(payload) + var wire_payload := payload.compress(FileAccess.COMPRESSION_GZIP) if gzip else payload + if chunked: + peer.put_data("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n".to_utf8_buffer()) + for offset in range(0, payload.size(), 32): + var part := payload.slice(offset, mini(offset + 32, payload.size())) + peer.put_data(("%x\r\n" % part.size()).to_utf8_buffer()) + peer.put_data(part) + peer.put_data("\r\n".to_utf8_buffer()) + peer.put_data("0\r\n\r\n".to_utf8_buffer()) + else: + var length := declared_length if declared_length >= 0 else wire_payload.size() + var encoding := "Content-Encoding: gzip\r\n" if gzip else "" + peer.put_data(("HTTP/1.1 200 OK\r\nContent-Length: %d\r\n%sConnection: close\r\n\r\n" % [length, encoding]).to_utf8_buffer()) + peer.put_data(wire_payload) func _exit_tree() -> void: for peer: StreamPeerTCP in peers: @@ -59,6 +74,47 @@ func _run() -> void: _check(results.size() == 1, "Completed request must not later time out") _check(request_owner.get_child_count() == 0, "Completed request must release its owner child") + fixture.body = '{"padding":"%s"}' % "x".repeat(256) + fixture.declared_length = fixture.body.to_utf8_buffer().size() + results.clear() + EnvJsonModule.load_dict_from_http(request_owner, url, _capture, 1.0, false, "v", 64) + await _wait_for_result() + _check(results.size() == 1 and results[0].error_code == EnvJsonModule.ErrorCode.BODY_TOO_LARGE, "Declared oversized body must return typed size error") + if results.size() == 1: + _check(results[0].declared_bytes > 64, "Declared oversized body must report bounded length metadata") + await _settle(0.05) + _check(request_owner.get_child_count() == 0, "Declared oversized request must release its owner child") + + fixture.chunked = true + fixture.declared_length = -1 + results.clear() + EnvJsonModule.load_dict_from_http(request_owner, url, _capture, 1.0, false, "v", 64) + await _wait_for_result() + _check(results.size() == 1 and results[0].error_code == EnvJsonModule.ErrorCode.BODY_TOO_LARGE, "Streamed oversized body must return typed size error") + if results.size() == 1: + _check(results[0].received_bytes <= 64, "Streamed oversized body must not retain bytes beyond the cap") + await _settle(0.05) + _check(request_owner.get_child_count() == 0, "Streamed oversized request must release its owner child") + + fixture.chunked = false + fixture.gzip = true + results.clear() + EnvJsonModule.load_dict_from_http(request_owner, url, _capture, 1.0, false, "v", 64) + await _wait_for_result() + _check(results.size() == 1 and results[0].error_code == EnvJsonModule.ErrorCode.BODY_TOO_LARGE, "Compressed oversized body must enforce the decompressed cap") + if results.size() == 1: + _check(results[0].received_bytes <= 64, "Compressed oversized body must not retain decompressed bytes beyond the cap") + await _settle(0.05) + _check(request_owner.get_child_count() == 0, "Compressed oversized request must release its owner child") + + fixture.gzip = false + fixture.body = "{malformed" + results.clear() + EnvJsonModule.load_dict_from_http(request_owner, url, _capture, 1.0, false) + await _wait_for_result() + _check(results.size() == 1 and results[0].error_code == EnvJsonModule.ErrorCode.JSON_PARSE, "Malformed HTTP JSON must return typed parse error") + fixture.body = '{"ready":true}' + results.clear() fixture.respond = false var started := Time.get_ticks_usec() @@ -70,6 +126,7 @@ func _run() -> void: _check(results.size() == 1 and not results[0].success, "Unanswered HTTP request must time out") if results.size() == 1: _check(results[0].request_result == HTTPRequest.RESULT_TIMEOUT, "Preserve exact transport timeout result") + _check(results[0].error_code == EnvJsonModule.ErrorCode.TIMEOUT, "Deadline must return typed timeout error") await _settle(0.2) _check(results.size() == 1 and request_owner.get_child_count() == 0, "Timeout must complete once and release request") @@ -93,12 +150,17 @@ func _run() -> void: results.clear() EnvJsonModule.load_dict_from_http(request_owner, url, _capture, invalid, false) _check(results.size() == 1 and results[0].error_message == "invalid_timeout", "Invalid deadline must fail synchronously") + for invalid_size: int in [0, EnvJsonModule.MAX_CONFIGURABLE_BODY_BYTES + 1]: + results.clear() + EnvJsonModule.load_dict_from_http(request_owner, url, _capture, 1.0, false, "v", invalid_size) + _check(results.size() == 1 and results[0].error_code == EnvJsonModule.ErrorCode.INVALID_MAX_BODY_BYTES, "Invalid body cap must fail synchronously") _check(request_owner.get_child_count() == 0, "Invalid deadline must not allocate a request") request_owner.queue_free() fixture.queue_free() await process_frame if failures.is_empty(): + print("REACHED gd-env http_deadline_test assertions=1") print("PASS gd-env http_deadline_test") quit(0) else: diff --git a/tests/package_test.sh b/tests/package_test.sh new file mode 100755 index 0000000..a9abb34 --- /dev/null +++ b/tests/package_test.sh @@ -0,0 +1,233 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +GODOT=${GODOT_BIN:-godot} +ARCHIVE=${1:-"$ROOT_DIR/dist/@aviorstudio_gd-env.zip"} +EXPECTED="$ROOT_DIR/package/addon_manifest.txt" +WORK=$(mktemp -d) +if [ "${KEEP_TEST_WORK:-0}" = "1" ]; then + echo "Package test workspace: $WORK" +else + trap 'rm -rf "$WORK"' EXIT +fi + +test -f "$ARCHIVE" +"$ROOT_DIR/scripts/verify_package_checksum.sh" "$(dirname "$ARCHIVE")" + +checksum_control="$WORK/checksum-control" +mkdir -p "$checksum_control" +cp "$ARCHIVE" "$checksum_control/@aviorstudio_gd-env.zip" +printf '%064d @aviorstudio_gd-env.zip\n' 0 >"$checksum_control/@aviorstudio_gd-env.zip.sha256" +if "$ROOT_DIR/scripts/verify_package_checksum.sh" "$checksum_control" >/dev/null 2>&1; then + echo "FAIL: checksum gate accepted a mutated identity" >&2 + exit 1 +fi +echo "CONTROL_REJECTED gd-env package_checksum" +cp "$(dirname "$ARCHIVE")/@aviorstudio_gd-env.zip.sha256" "$checksum_control/" +"$ROOT_DIR/scripts/verify_package_checksum.sh" "$checksum_control" + +python3 - "$ARCHIVE" "$WORK/traversal.zip" "$WORK/symlink.zip" <<'PY' +import stat +import sys +import zipfile + +source, traversal, symlink = sys.argv[1:] +with zipfile.ZipFile(source) as src: + entries = [(item, src.read(item.filename)) for item in src.infolist()] +with zipfile.ZipFile(traversal, "w") as out: + for item, data in entries: + out.writestr(item, data) + out.writestr("../escape.gd", b"extends Node\n") +with zipfile.ZipFile(symlink, "w") as out: + for index, (item, data) in enumerate(entries): + if index == 0: + item.create_system = 3 + item.external_attr = (stat.S_IFLNK | 0o777) << 16 + data = b"plugin.gd" + out.writestr(item, data) +PY +for control in traversal symlink; do + if "$ROOT_DIR/scripts/verify_package_archive.sh" "$WORK/$control.zip" "$EXPECTED" >/dev/null 2>&1; then + echo "FAIL: package archive gate accepted $control control" >&2 + exit 1 + fi + echo "CONTROL_REJECTED gd-env package_$control" +done +"$ROOT_DIR/scripts/verify_package_archive.sh" "$ARCHIVE" "$EXPECTED" + +PROJECT="$WORK/project" +ADDON_DIR="$PROJECT/addons/@aviorstudio_gd-env" +mkdir -p "$ADDON_DIR" +unzip -q "$ARCHIVE" -d "$ADDON_DIR" + +cat >"$PROJECT/project.godot" <<'EOF' +[application] +config/name="gd-env packaged lifecycle fixture" + +[rendering] +renderer/rendering_method="gl_compatibility" +EOF + +cat >"$PROJECT/smoke.gd" <<'EOF' +extends SceneTree + +const EnvJsonModule = preload("res://addons/@aviorstudio_gd-env/src/env_json_module.gd") + +func _initialize() -> void: + var parsed = EnvJsonModule.parse_json_dict('{"installed":true}') + if not parsed.success or parsed.data.get("installed") != true: + push_error("Packaged autoload smoke failed") + quit(1) + return + print("REACHED gd-env packaged_smoke assertions=2") + print("PASS gd-env packaged_smoke") + quit(0) +EOF + +cat >"$PROJECT/disable_plugin.gd" <<'EOF' +@tool +extends SceneTree + +func _initialize() -> void: + call_deferred("_disable") + +func _disable() -> void: + var stop := Time.get_ticks_usec() + 20000000 + while (EditorInterface.get_resource_filesystem().is_scanning() \ + or not EditorInterface.is_plugin_enabled("res://addons/@aviorstudio_gd-env/plugin.cfg")) \ + and Time.get_ticks_usec() < stop: + await process_frame + if not EditorInterface.is_plugin_enabled("res://addons/@aviorstudio_gd-env/plugin.cfg"): + push_error("Packaged plugin did not become enabled") + quit(1) + return + EditorInterface.set_plugin_enabled("res://addons/@aviorstudio_gd-env/plugin.cfg", false) + await process_frame + if EditorInterface.is_plugin_enabled("res://addons/@aviorstudio_gd-env/plugin.cfg"): + push_error("Packaged plugin did not disable") + quit(1) + return + for frame in range(10): + await process_frame + ProjectSettings.save() + print("REACHED gd-env packaged_disable assertions=2") + print("PASS gd-env packaged_disable") + quit(0) +EOF + +cat >"$PROJECT/enable_plugin.gd" <<'EOF' +@tool +extends SceneTree + +func _initialize() -> void: + call_deferred("_enable") + +func _enable() -> void: + var stop := Time.get_ticks_usec() + 20000000 + while EditorInterface.get_resource_filesystem().is_scanning() and Time.get_ticks_usec() < stop: + await process_frame + EditorInterface.set_plugin_enabled("res://addons/@aviorstudio_gd-env/plugin.cfg", true) + await process_frame + if not EditorInterface.is_plugin_enabled("res://addons/@aviorstudio_gd-env/plugin.cfg"): + push_error("Packaged plugin did not enable") + quit(1) + return + ProjectSettings.save() + print("REACHED gd-env packaged_enable assertions=1") + print("PASS gd-env packaged_enable") + quit(0) +EOF + +cat >"$PROJECT/editor_wait.gd" <<'EOF' +@tool +extends SceneTree + +func _initialize() -> void: + call_deferred("_wait") + +func _wait() -> void: + var stop := Time.get_ticks_usec() + 20000000 + while EditorInterface.get_resource_filesystem().is_scanning() and Time.get_ticks_usec() < stop: + await process_frame + if EditorInterface.get_resource_filesystem().is_scanning(): + push_error("Editor filesystem scan timed out") + quit(1) + return + await process_frame + print("REACHED gd-env packaged_editor assertions=1") + print("PASS gd-env packaged_editor") + quit(0) +EOF + +export XDG_DATA_HOME="$WORK/data" +export XDG_CONFIG_HOME="$WORK/config" + +run_editor() { + local log=$1 + shift + set +e + timeout --signal=TERM --kill-after=5 30 "$GODOT" --headless --editor --path "$PROJECT" "$@" >"$log" 2>&1 + local status=$? + set -e + while IFS= read -r line; do printf '%s\n' "$line"; done <"$log" + grep -Ev "^ERROR: [0-9]+ RID allocations? of type '.+' were leaked at exit\.$" "$log" >"$log.filtered" || true + grep -Ev '^ERROR: [0-9]+ resources still in use at exit \(run with --verbose for details\)\.$' "$log.filtered" >"$log.filtered2" || true + if [ "$status" -ne 0 ] || grep -Eq '(^|[[:space:]])(SCRIPT ERROR:|ERROR:|FAIL:)' "$log.filtered2"; then + echo "FAIL: packaged editor lifecycle command failed with status $status" >&2 + return 1 + fi +} + +# These two exact Godot 4.7.2 headless MainLoop teardown diagnostics are also +# reproduced by the delivered gd-router known-good package lifecycle fixture. +run_editor "$WORK/enable.log" --script res://enable_plugin.gd +grep -q '^GdEnv="\*' "$PROJECT/project.godot" +run_editor "$WORK/restart-enabled.log" --quit-after 2 +GODOT_PROJECT_DIR="$PROJECT" "$ROOT_DIR/tests/run_godot_case.sh" "$PROJECT/smoke.gd" packaged_smoke 30 "$WORK/smoke.log" + +run_editor "$WORK/disable.log" --script res://disable_plugin.gd +run_editor "$WORK/restart-disabled.log" --quit-after 2 +if grep -q '^GdEnv=' "$PROJECT/project.godot" || grep -q '^plugin_owns_autoload=' "$PROJECT/project.godot"; then + echo "FAIL: disabling packaged plugin retained owned project settings" >&2 + exit 1 +fi + +mkdir -p "$PROJECT/consumer" +printf 'extends Node\nconst MARKER := "consumer-owned"\n' >"$PROJECT/consumer/autoload.gd" +cat >"$PROJECT/consumer_smoke.gd" <<'EOF' +extends SceneTree + +func _initialize() -> void: + var configured_path := str(ProjectSettings.get_setting("autoload/GdEnv", "")).trim_prefix("*") + if configured_path.begins_with("uid://"): + var uid := ResourceUID.text_to_id(configured_path) + if ResourceUID.has_id(uid): + configured_path = ResourceUID.get_id_path(uid) + if configured_path != "res://consumer/autoload.gd": + push_error("Consumer-owned autoload was replaced: %s" % configured_path) + quit(1) + return + print("REACHED gd-env consumer_autoload assertions=1") + print("PASS gd-env consumer_autoload") + quit(0) +EOF +if grep -Fqx '[autoload]' "$PROJECT/project.godot"; then + perl -0pi -e 's/\[autoload\]\n/\[autoload\]\n\nGdEnv="*res:\/\/consumer\/autoload.gd"\n/' "$PROJECT/project.godot" +else + printf '\n[autoload]\n\nGdEnv="*res://consumer/autoload.gd"\n' >>"$PROJECT/project.godot" +fi +run_editor "$WORK/consumer-enable.log" --script res://enable_plugin.gd +run_editor "$WORK/consumer-disable.log" --script res://disable_plugin.gd +run_editor "$WORK/consumer-restart.log" --quit-after 2 +GODOT_PROJECT_DIR="$PROJECT" "$ROOT_DIR/tests/run_godot_case.sh" "$PROJECT/consumer_smoke.gd" consumer_autoload 30 "$WORK/consumer-smoke.log" +if grep -q '^plugin_owns_autoload=' "$PROJECT/project.godot"; then + echo "FAIL: consumer-owned autoload acquired plugin ownership marker" >&2 + exit 1 +fi + +(cd "$ADDON_DIR" && find . -type f -printf '%P\0' | LC_ALL=C sort -z | xargs -0 sha256sum) >"$WORK/installed-tree.sha256" +sha256sum "$ARCHIVE" +sha256sum "$WORK/installed-tree.sha256" +echo "REACHED gd-env packaged_lifecycle assertions=8" +echo "PASS gd-env packaged_lifecycle" diff --git a/tests/run_godot_case.sh b/tests/run_godot_case.sh new file mode 100755 index 0000000..459cc00 --- /dev/null +++ b/tests/run_godot_case.sh @@ -0,0 +1,54 @@ +#!/bin/bash +set -euo pipefail + +if [ "$#" -ne 4 ]; then + echo "usage: $0 TEST_SCRIPT CASE_NAME TIMEOUT_SECONDS LOG_FILE" >&2 + exit 2 +fi + +test_script=$1 +case_name=$2 +timeout_seconds=$3 +log_file=$4 +godot=${GODOT_BIN:-godot} +project_dir=${GODOT_PROJECT_DIR:-"$(cd "$(dirname "$test_script")/.." && pwd)"} + +if [ ! -f "$test_script" ]; then + echo "FAIL: missing test script: $test_script" >&2 + exit 1 +fi +if ! command -v "$godot" >/dev/null 2>&1 && [ ! -x "$godot" ]; then + echo "FAIL: Godot binary is unavailable: $godot" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$log_file")" +set +e +timeout --signal=TERM --kill-after=2 "$timeout_seconds" \ + "$godot" --headless --path "$project_dir" \ + --script "$test_script" >"$log_file" 2>&1 +status=$? +set -e + +while IFS= read -r line; do printf '%s\n' "$line"; done <"$log_file" + +if [ "$status" -eq 124 ] || [ "$status" -eq 137 ]; then + echo "FAIL: $case_name timed out after ${timeout_seconds}s" >&2 + exit 1 +fi +if [ "$status" -ne 0 ]; then + echo "FAIL: $case_name exited with status $status" >&2 + exit 1 +fi +if grep -Eq '^(ERROR:|SCRIPT ERROR:|FAIL:)' "$log_file"; then + echo "FAIL: $case_name emitted an unexpected engine/test error" >&2 + exit 1 +fi +if [ "$(grep -Ec "^REACHED gd-env ${case_name} assertions=[1-9][0-9]*$" "$log_file")" -ne 1 ]; then + echo "FAIL: $case_name did not prove its assertions were reached exactly once" >&2 + exit 1 +fi +if [ "$(grep -Ec "^PASS gd-env ${case_name}$" "$log_file")" -ne 1 ]; then + echo "FAIL: $case_name did not emit exactly one PASS marker" >&2 + exit 1 +fi diff --git a/tests/serve_web_fixture.py b/tests/serve_web_fixture.py new file mode 100755 index 0000000..728aeff --- /dev/null +++ b/tests/serve_web_fixture.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +import http.server +import json +import pathlib +import socketserver +import sys + +root = pathlib.Path(sys.argv[1]).resolve() +port = int(sys.argv[2]) + +class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=root, **kwargs) + + def end_headers(self): + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Cross-Origin-Resource-Policy", "cross-origin") + self.send_header("X-Content-Type-Options", "nosniff") + super().end_headers() + + def do_GET(self): + if self.path in ("/small.json", "/oversized.json", "/custom.json"): + padding = "ok" if self.path == "/small.json" else "x" * (1024 * 1024 + 4096) + body = json.dumps({"padding": padding}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + super().do_GET() + +with socketserver.TCPServer(("127.0.0.1", port), Handler) as server: + server.serve_forever() diff --git a/tests/suite_manifest.txt b/tests/suite_manifest.txt new file mode 100644 index 0000000..c447ed7 --- /dev/null +++ b/tests/suite_manifest.txt @@ -0,0 +1,5 @@ +# Every shipped Godot behavior suite is explicit so deleting/renaming one fails. +tests/dotenv_module_test.gd +tests/env_json_module_test.gd +tests/env_var_module_test.gd +tests/http_deadline_test.gd diff --git a/tests/test.sh b/tests/test.sh index a7ecf27..ea74ea4 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -1,20 +1,45 @@ #!/bin/bash set -euo pipefail + SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd) -GODOT="${GODOT_BIN:-godot}" +MANIFEST="$SCRIPT_DIR/suite_manifest.txt" +RUN_CASE="$SCRIPT_DIR/run_godot_case.sh" FAILURES=0 LOG_DIR=$(mktemp -d) trap 'rm -rf "$LOG_DIR"' EXIT + export XDG_DATA_HOME="$LOG_DIR/data" export XDG_CONFIG_HOME="$LOG_DIR/config" -for test in "$SCRIPT_DIR"/*_test.gd; do - echo "Running $(basename "$test")..." - log="$LOG_DIR/$(basename "$test").log" - if ! timeout 30 "$GODOT" --headless --path "$ROOT_DIR" --script "$test" 2>&1 | tee "$log"; then - FAILURES=$((FAILURES + 1)) - elif grep -Eq '^(ERROR:|SCRIPT ERROR:|FAIL:)' "$log" || ! grep -q '^PASS gd-env ' "$log"; then + +if [ ! -s "$MANIFEST" ]; then + echo "FAIL: missing or empty Godot suite manifest: $MANIFEST" >&2 + exit 1 +fi + +mapfile -t tests < <(grep -Ev '^[[:space:]]*(#|$)' "$MANIFEST") +if [ "${#tests[@]}" -eq 0 ]; then + echo "FAIL: Godot suite manifest has no reachable tests" >&2 + exit 1 +fi + +for relative_test in "${tests[@]}"; do + test_path="$ROOT_DIR/$relative_test" + case_name=$(basename "$relative_test" .gd) + echo "Running $relative_test..." + if ! "$RUN_CASE" "$test_path" "$case_name" 30 "$LOG_DIR/$case_name.log"; then FAILURES=$((FAILURES + 1)) fi done -exit $FAILURES + +if ! "$SCRIPT_DIR/gate/test_gate.sh" "$LOG_DIR/gate"; then + FAILURES=$((FAILURES + 1)) +fi + +if [ "$FAILURES" -ne 0 ]; then + echo "FAIL: $FAILURES gd-env suite group(s) failed" >&2 + exit 1 +fi + +echo "REACHED gd-env suite assertions=${#tests[@]}" +echo "PASS gd-env suite" diff --git a/tests/web_fixture/export_presets.cfg b/tests/web_fixture/export_presets.cfg new file mode 100644 index 0000000..1c9a176 --- /dev/null +++ b/tests/web_fixture/export_presets.cfg @@ -0,0 +1,24 @@ +[preset.0] +name="Web" +platform="Web" +runnable=true +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="web/index.html" +script_export_mode=2 + +[preset.0.options] +variant/extensions_support=false +variant/thread_support=false +vram_texture_compression/for_desktop=true +vram_texture_compression/for_mobile=false +html/export_icon=true +html/custom_html_shell="" +html/head_include="" +html/canvas_resize_policy=2 +html/focus_canvas_on_start=true +html/experimental_virtual_keyboard=false +progressive_web_app/enabled=false diff --git a/tests/web_fixture/main.gd b/tests/web_fixture/main.gd new file mode 100644 index 0000000..7712a8b --- /dev/null +++ b/tests/web_fixture/main.gd @@ -0,0 +1,36 @@ +extends Node + +const EnvJsonModule = preload("res://addons/@aviorstudio_gd-env/src/env_json_module.gd") + +func _ready() -> void: + var small: EnvJsonModule.LoadResult = await _load_default("/small.json") + var oversized: EnvJsonModule.LoadResult = await _load_default("/oversized.json") + var custom: EnvJsonModule.LoadResult = await _load_with_cap("/custom.json", 2 * 1024 * 1024) + var invalid: EnvJsonModule.LoadResult = await _load_with_cap("/small.json", 16 * 1024 * 1024 + 1) + var passed := small.success \ + and oversized.error_code == EnvJsonModule.ErrorCode.BODY_TOO_LARGE \ + and custom.success \ + and invalid.error_code == EnvJsonModule.ErrorCode.INVALID_MAX_BODY_BYTES + var report := { + "pass": passed, + "default_bytes": EnvJsonModule.DEFAULT_MAX_BODY_BYTES, + "maximum_bytes": EnvJsonModule.MAX_CONFIGURABLE_BODY_BYTES, + "timeout_seconds": 10, + "oversized_error": oversized.error_message, + } + var label := "PASS gd-env web HTTP bounds" if passed else "FAIL gd-env web HTTP bounds" + JavaScriptBridge.eval("window.gdEnvWebResult=%s;document.title=%s;var e=document.createElement('pre');e.id='gd-env-result';e.textContent=%s;document.body.appendChild(e);" % [JSON.stringify(report), JSON.stringify(label), JSON.stringify(label + "\n1 MiB default · 16 MiB maximum · 10 s deadline\n" + oversized.error_message)]) + +func _load_default(path: String) -> EnvJsonModule.LoadResult: + var state := {"done": false, "result": null} + EnvJsonModule.load_dict_from_http(self, path, func(result): state.result = result; state.done = true, 10.0, false) + while not state.done: + await get_tree().process_frame + return state.result + +func _load_with_cap(path: String, cap: int) -> EnvJsonModule.LoadResult: + var state := {"done": false, "result": null} + EnvJsonModule.load_dict_from_http(self, path, func(result): state.result = result; state.done = true, 10.0, false, "v", cap) + while not state.done: + await get_tree().process_frame + return state.result diff --git a/tests/web_fixture/main.tscn b/tests/web_fixture/main.tscn new file mode 100644 index 0000000..ee28584 --- /dev/null +++ b/tests/web_fixture/main.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource path="res://main.gd" type="Script" id="1"] + +[node name="WebAcceptance" type="Node"] +script = ExtResource("1") diff --git a/tests/web_fixture/project.godot b/tests/web_fixture/project.godot new file mode 100644 index 0000000..60302c4 --- /dev/null +++ b/tests/web_fixture/project.godot @@ -0,0 +1,12 @@ +config_version=5 + +[application] +config/name="gd-env web HTTP acceptance" +run/main_scene="res://main.tscn" + +[display] +window/size/viewport_width=900 +window/size/viewport_height=500 + +[rendering] +renderer/rendering_method="gl_compatibility" diff --git a/tests/web_package_test.sh b/tests/web_package_test.sh new file mode 100755 index 0000000..de3c03d --- /dev/null +++ b/tests/web_package_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +GODOT=${GODOT_BIN:-godot} +ARCHIVE=${1:-"$ROOT_DIR/dist/@aviorstudio_gd-env.zip"} +OUTPUT=${WEB_EXPORT_DIR:-"$ROOT_DIR/dist/web"} +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +mkdir -p "$WORK/project/addons/@aviorstudio_gd-env" "$OUTPUT" +unzip -q "$ARCHIVE" -d "$WORK/project/addons/@aviorstudio_gd-env" +cp "$ROOT_DIR/tests/web_fixture/project.godot" "$ROOT_DIR/tests/web_fixture/main.gd" \ + "$ROOT_DIR/tests/web_fixture/main.tscn" "$ROOT_DIR/tests/web_fixture/export_presets.cfg" "$WORK/project/" + +log="$WORK/export.log" +set +e +timeout --signal=TERM --kill-after=5 120 "$GODOT" --headless --path "$WORK/project" \ + --export-release Web "$OUTPUT/index.html" >"$log" 2>&1 +status=$? +set -e +while IFS= read -r line; do printf '%s\n' "$line"; done <"$log" +if [ "$status" -ne 0 ] || grep -Eq '(^|[[:space:]])(SCRIPT ERROR:|ERROR:|FAIL:)' "$log"; then + echo "FAIL: packaged web export failed with status $status" >&2 + exit 1 +fi +test -s "$OUTPUT/index.html" +test -s "$OUTPUT/index.wasm" +echo "REACHED gd-env packaged_web_export assertions=2" +echo "PASS gd-env packaged_web_export"