From 8b5f0446138bd30df25b35cccc4244710f9f1ff4 Mon Sep 17 00:00:00 2001 From: DNT_OF <3174600270@qq.com> Date: Sat, 7 Nov 2026 14:33:39 +0000 Subject: [PATCH 1/5] ci: add standalone logic regression workflow Run the four net8.0 xUnit suites separately on push, pull request, and workflow_dispatch without building or publishing SLDataAPI.dll. Co-authored-by: DNT_OF --- .github/workflows/functional-tests.yml | 62 ++++++++++++++++++++++++++ README.md | 19 ++++++++ 2 files changed, 81 insertions(+) create mode 100644 .github/workflows/functional-tests.yml diff --git a/.github/workflows/functional-tests.yml b/.github/workflows/functional-tests.yml new file mode 100644 index 0000000..82ff467 --- /dev/null +++ b/.github/workflows/functional-tests.yml @@ -0,0 +1,62 @@ +# 仅运行独立逻辑测试,不构建或发布 SLDataAPI.dll。 +# 不下载游戏程序集、LabAPI、Unity,不使用签名密钥或真实 token。 +name: 功能逻辑测试 + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: functional-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: "1" + DOTNET_NOLOGO: "1" + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1" + +jobs: + test: + name: ${{ matrix.suite }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + suite: [Auth, Features, Update, WebDav] + steps: + - name: 检出源码 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 1 + + - name: 安装 .NET 8 SDK + uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0 + with: + dotnet-version: "8.0.x" + # 只安装指定的 8.0.x。NuGet 缓存需要 actions: write,此处不启用。 + skip-lts-prepass: true + + - name: 恢复测试依赖 + env: + SUITE: ${{ matrix.suite }} + run: dotnet restore "tests/SLDataAPI.${SUITE}.Tests/SLDataAPI.${SUITE}.Tests.csproj" + + - name: 运行逻辑测试 + env: + SUITE: ${{ matrix.suite }} + run: dotnet test "tests/SLDataAPI.${SUITE}.Tests/SLDataAPI.${SUITE}.Tests.csproj" --configuration Release --no-restore --logger "trx;LogFileName=${SUITE}.trx" --results-directory "TestResults/${SUITE}" + + - name: 保存测试报告 + if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: test-results-${{ matrix.suite }} + path: TestResults/${{ matrix.suite }}/*.trx + if-no-files-found: warn + retention-days: 14 diff --git a/README.md b/README.md index cc4c06b..20ccea8 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,25 @@ Content-Type: application/json --- +## 逻辑回归测试 + +`tests/` 下有四套独立的 net8.0 xUnit 项目:`SLDataAPI.Auth.Tests`、`SLDataAPI.Features.Tests`、`SLDataAPI.Update.Tests`、`SLDataAPI.WebDav.Tests`。它们直接编译对应的纯逻辑源文件,不引用主工程 `SLDataAPI.csproj`,因此不需要游戏程序集、LabAPI 或 Unity,也不会编译、签名或打包 `SLDataAPI.dll`。 + +这些测试只做逻辑回归:鉴权与路径沙箱、节日 / 端点入参、更新检查与强名称判定、WebDAV 上传规则。不能代替进服集成,也不能说明完整插件在 SCP:SL 里可用。官方插件的编译与发布仍在本机完成。 + +本机已安装 .NET 8 SDK 时: + +```bash +dotnet test tests/SLDataAPI.Auth.Tests/SLDataAPI.Auth.Tests.csproj -c Release +dotnet test tests/SLDataAPI.Features.Tests/SLDataAPI.Features.Tests.csproj -c Release +dotnet test tests/SLDataAPI.Update.Tests/SLDataAPI.Update.Tests.csproj -c Release +dotnet test tests/SLDataAPI.WebDav.Tests/SLDataAPI.WebDav.Tests.csproj -c Release +``` + +推送、Pull Request 和手动 `workflow_dispatch` 会走 `.github/workflows/functional-tests.yml`:在 GitHub 托管的 `ubuntu-24.04` 上分别跑这四套。任一失败会使该检查失败,其余套件仍会跑完,并保留 TRX 报告。 + +--- + ## 支持 - QQ 群:984840871 From 6d68d658d9f2f20c6190ffc13a636e994471fd7a Mon Sep 17 00:00:00 2001 From: DNT_OF <3174600270@qq.com> Date: Sat, 7 Nov 2026 14:49:05 +0000 Subject: [PATCH 2/5] ci: run logic tests without non-DNTOF actions The repository Actions policy rejects actions owned outside DNTOF. Check out with git, install the .NET 8 SDK from dotnet-install.sh, and upload TRX through the job's own results service. Co-authored-by: DNT_OF --- .github/scripts/upload-trx.py | 169 +++++++++++++++++++++++++ .github/workflows/functional-tests.yml | 35 ++--- README.md | 2 +- 3 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 .github/scripts/upload-trx.py diff --git a/.github/scripts/upload-trx.py b/.github/scripts/upload-trx.py new file mode 100644 index 0000000..aadf58b --- /dev/null +++ b/.github/scripts/upload-trx.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Upload a TRX zip through the Actions results service. + +The repository policy only allows actions owned by DNTOF, so this job cannot +call actions/upload-artifact. The runner still provides ACTIONS_RUNTIME_TOKEN +and ACTIONS_RESULTS_URL for the job's own artifact storage. +""" + +import base64 +import datetime +import hashlib +import json +import os +import sys +import urllib.error +import urllib.request +import zipfile +from pathlib import Path + + +def fail(message: str) -> None: + print(message, file=sys.stderr) + raise SystemExit(1) + + +def redact(text: str) -> str: + token = os.environ.get("ACTIONS_RUNTIME_TOKEN", "") + if token: + text = text.replace(token, "[REDACTED]") + # Signed blob URLs carry a query signature. Keep only the host. + redacted = [] + for line in text.splitlines(): + if "sig=" in line or "https://" in line: + redacted.append("[redacted response line]") + else: + redacted.append(line) + return "\n".join(redacted)[:800] + + +def backend_ids(token: str) -> tuple[str, str]: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload)) + for scope in str(claims.get("scp", "")).split(): + parts = scope.split(":") + if len(parts) == 3 and parts[0] == "Actions.Results": + return parts[1], parts[2] + fail("ACTIONS_RUNTIME_TOKEN does not contain an Actions.Results scope") + return "", "" + + +def request_json(url: str, token: str, body: dict) -> dict: + data = json.dumps(body).encode() + req = urllib.request.Request( + url, + data=data, + method="POST", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "sldataapi-trx-upload", + }, + ) + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as exc: + fail(f"{url.rsplit('/', 1)[-1]} HTTP {exc.code}: {redact(exc.read().decode('utf-8', 'replace'))}") + return {} + + +def find_signed_url(value: object) -> str: + if isinstance(value, dict): + for key, item in value.items(): + normalized = key.replace("_", "").lower() + if normalized in {"signeduploadurl", "signedurl"} and isinstance(item, str) and item.startswith("https://"): + return item + found = find_signed_url(item) + if found: + return found + elif isinstance(value, list): + for item in value: + found = find_signed_url(item) + if found: + return found + return "" + + +def main() -> None: + suite = os.environ.get("SUITE", "") + if not suite: + fail("SUITE is empty") + source = Path("TestResults") / suite + files = sorted(source.glob("*.trx")) if source.is_dir() else [] + if not files: + print(f"warning: no TRX files in {source}") + return + + token = os.environ.get("ACTIONS_RUNTIME_TOKEN", "") + results = os.environ.get("ACTIONS_RESULTS_URL", "") + if not token or not results: + fail("ACTIONS_RUNTIME_TOKEN or ACTIONS_RESULTS_URL is missing") + run_id, job_id = backend_ids(token) + origin = results.split("/twirp/")[0].rstrip("/") + if "://" not in origin: + fail("ACTIONS_RESULTS_URL is not a URL") + + archive = Path(os.environ.get("RUNNER_TEMP", "/tmp")) / f"{suite}.trx.zip" + with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in files: + zf.write(path, arcname=path.name) + payload = archive.read_bytes() + digest = hashlib.sha256(payload).hexdigest() + expires = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=14)).strftime("%Y-%m-%dT%H:%M:%SZ") + name = f"test-results-{suite}" + service = f"{origin}/twirp/github.actions.results.api.v1.ArtifactService" + created = request_json( + f"{service}/CreateArtifact", + token, + { + "workflowRunBackendId": run_id, + "workflowJobRunBackendId": job_id, + "name": name, + "version": 7, + "mimeType": {"value": "application/zip"}, + "expiresAt": expires, + }, + ) + if created.get("ok") is False: + fail(f"CreateArtifact was not accepted: {redact(json.dumps(created))}") + upload_url = find_signed_url(created) + if not upload_url: + fail(f"CreateArtifact response did not include an upload URL. Keys: {list(created)}") + + put = urllib.request.Request( + upload_url, + data=payload, + method="PUT", + headers={ + "Content-Type": "application/zip", + "x-ms-blob-type": "BlockBlob", + }, + ) + try: + with urllib.request.urlopen(put) as resp: + status = resp.status + except urllib.error.HTTPError as exc: + fail(f"blob upload HTTP {exc.code}: {redact(exc.read().decode('utf-8', 'replace'))}") + if status not in {200, 201}: + fail(f"blob upload HTTP {status}") + + finalized = request_json( + f"{service}/FinalizeArtifact", + token, + { + "workflowRunBackendId": run_id, + "workflowJobRunBackendId": job_id, + "name": name, + "size": str(len(payload)), + "hash": {"value": f"sha256:{digest}"}, + }, + ) + if finalized.get("ok") is False: + fail(f"FinalizeArtifact was not accepted: {redact(json.dumps({k: v for k, v in finalized.items() if 'url' not in k.lower()}))}") + print(f"uploaded {name} ({len(files)} trx, {len(payload)} bytes)") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/functional-tests.yml b/.github/workflows/functional-tests.yml index 82ff467..1223239 100644 --- a/.github/workflows/functional-tests.yml +++ b/.github/workflows/functional-tests.yml @@ -1,5 +1,6 @@ # 仅运行独立逻辑测试,不构建或发布 SLDataAPI.dll。 # 不下载游戏程序集、LabAPI、Unity,不使用签名密钥或真实 token。 +# 本仓库的 Actions 策略只允许 DNTOF 拥有的 action,因此这里不用 actions/*。 name: 功能逻辑测试 on: @@ -30,17 +31,24 @@ jobs: suite: [Auth, Features, Update, WebDav] steps: - name: 检出源码 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - fetch-depth: 1 + env: + REPOSITORY: ${{ github.repository }} + REF: ${{ github.ref }} + run: | + set -euo pipefail + git init -q + git remote add origin "https://github.com/${REPOSITORY}.git" + git fetch --depth 1 origin "${REF}" + git checkout --detach FETCH_HEAD - name: 安装 .NET 8 SDK - uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0 - with: - dotnet-version: "8.0.x" - # 只安装指定的 8.0.x。NuGet 缓存需要 actions: write,此处不启用。 - skip-lts-prepass: true + run: | + set -euo pipefail + curl -fsSL https://dot.net/v1/dotnet-install.sh -o "${RUNNER_TEMP}/dotnet-install.sh" + bash "${RUNNER_TEMP}/dotnet-install.sh" --channel 8.0 --install-dir "${RUNNER_TEMP}/dotnet" + echo "${RUNNER_TEMP}/dotnet" >> "${GITHUB_PATH}" + echo "DOTNET_ROOT=${RUNNER_TEMP}/dotnet" >> "${GITHUB_ENV}" + "${RUNNER_TEMP}/dotnet/dotnet" --list-sdks - name: 恢复测试依赖 env: @@ -54,9 +62,6 @@ jobs: - name: 保存测试报告 if: ${{ !cancelled() }} - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: test-results-${{ matrix.suite }} - path: TestResults/${{ matrix.suite }}/*.trx - if-no-files-found: warn - retention-days: 14 + env: + SUITE: ${{ matrix.suite }} + run: python3 .github/scripts/upload-trx.py diff --git a/README.md b/README.md index 20ccea8..f8cf0ef 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ dotnet test tests/SLDataAPI.Update.Tests/SLDataAPI.Update.Tests.csproj -c Releas dotnet test tests/SLDataAPI.WebDav.Tests/SLDataAPI.WebDav.Tests.csproj -c Release ``` -推送、Pull Request 和手动 `workflow_dispatch` 会走 `.github/workflows/functional-tests.yml`:在 GitHub 托管的 `ubuntu-24.04` 上分别跑这四套。任一失败会使该检查失败,其余套件仍会跑完,并保留 TRX 报告。 +推送、Pull Request 和手动 `workflow_dispatch` 会走 `.github/workflows/functional-tests.yml`:在 GitHub 托管的 `ubuntu-24.04` 上分别跑这四套。任一失败会使该检查失败,其余套件仍会跑完,并保留 TRX 报告。仓库的 Actions 策略只允许 DNTOF 拥有的 action,所以这条工作流用 git 检出、官方 `dotnet-install.sh` 安装 .NET 8,不调用其他组织的 action。 --- From 876d1634f387210f087d98e46bd5f67aeff6c359 Mon Sep 17 00:00:00 2001 From: DNT_OF <3174600270@qq.com> Date: Sat, 7 Nov 2026 14:50:39 +0000 Subject: [PATCH 3/5] ci: upload TRX from a repository-local action Plain run steps do not receive the Actions results token. Use a local JavaScript action, which this repository's owner-only action policy allows. Co-authored-by: DNT_OF --- .github/actions/upload-trx/action.yml | 9 +++++++++ .github/actions/upload-trx/index.js | 13 +++++++++++++ .github/scripts/upload-trx.py | 6 +++++- .github/workflows/functional-tests.yml | 6 +++--- 4 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 .github/actions/upload-trx/action.yml create mode 100644 .github/actions/upload-trx/index.js diff --git a/.github/actions/upload-trx/action.yml b/.github/actions/upload-trx/action.yml new file mode 100644 index 0000000..465b34c --- /dev/null +++ b/.github/actions/upload-trx/action.yml @@ -0,0 +1,9 @@ +name: Upload TRX +description: Upload one suite's TRX report from this repository. +inputs: + suite: + description: Test suite name + required: true +runs: + using: node24 + main: index.js diff --git a/.github/actions/upload-trx/index.js b/.github/actions/upload-trx/index.js new file mode 100644 index 0000000..1f8d65a --- /dev/null +++ b/.github/actions/upload-trx/index.js @@ -0,0 +1,13 @@ +const { spawnSync } = require('child_process') + +// The results token is injected for JavaScript actions, not for plain run steps. +process.env.SUITE = process.env.INPUT_SUITE || '' +const result = spawnSync('python3', ['.github/scripts/upload-trx.py'], { + stdio: 'inherit', + env: process.env, +}) +if (result.error) { + console.error(result.error.message) + process.exit(1) +} +process.exit(result.status === null ? 1 : result.status) diff --git a/.github/scripts/upload-trx.py b/.github/scripts/upload-trx.py index aadf58b..e9b1984 100644 --- a/.github/scripts/upload-trx.py +++ b/.github/scripts/upload-trx.py @@ -99,7 +99,11 @@ def main() -> None: token = os.environ.get("ACTIONS_RUNTIME_TOKEN", "") results = os.environ.get("ACTIONS_RESULTS_URL", "") if not token or not results: - fail("ACTIONS_RUNTIME_TOKEN or ACTIONS_RESULTS_URL is missing") + names = sorted(name for name in os.environ if name.startswith("ACTIONS_")) + fail( + "ACTIONS_RUNTIME_TOKEN or ACTIONS_RESULTS_URL is missing. " + f"ACTIONS_* names present: {', '.join(names) if names else '(none)'}" + ) run_id, job_id = backend_ids(token) origin = results.split("/twirp/")[0].rstrip("/") if "://" not in origin: diff --git a/.github/workflows/functional-tests.yml b/.github/workflows/functional-tests.yml index 1223239..b937e4d 100644 --- a/.github/workflows/functional-tests.yml +++ b/.github/workflows/functional-tests.yml @@ -62,6 +62,6 @@ jobs: - name: 保存测试报告 if: ${{ !cancelled() }} - env: - SUITE: ${{ matrix.suite }} - run: python3 .github/scripts/upload-trx.py + uses: ./.github/actions/upload-trx + with: + suite: ${{ matrix.suite }} From 92bcaec25f8b076dad0933fb4ce574dd54beeca2 Mon Sep 17 00:00:00 2001 From: DNT_OF <3174600270@qq.com> Date: Sat, 7 Nov 2026 14:52:06 +0000 Subject: [PATCH 4/5] ci: send artifact metadata with proto field names CreateArtifact rejected camelCase JSON. Match the results service's protobuf JSON, including plain strings for wrapper fields. Co-authored-by: DNT_OF --- .github/scripts/upload-trx.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/scripts/upload-trx.py b/.github/scripts/upload-trx.py index e9b1984..11ce6d3 100644 --- a/.github/scripts/upload-trx.py +++ b/.github/scripts/upload-trx.py @@ -118,16 +118,17 @@ def main() -> None: expires = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=14)).strftime("%Y-%m-%dT%H:%M:%SZ") name = f"test-results-{suite}" service = f"{origin}/twirp/github.actions.results.api.v1.ArtifactService" + # protobuf-ts sends proto field names. Wrapper fields are plain JSON strings. created = request_json( f"{service}/CreateArtifact", token, { - "workflowRunBackendId": run_id, - "workflowJobRunBackendId": job_id, + "workflow_run_backend_id": run_id, + "workflow_job_run_backend_id": job_id, "name": name, "version": 7, - "mimeType": {"value": "application/zip"}, - "expiresAt": expires, + "mime_type": "application/zip", + "expires_at": expires, }, ) if created.get("ok") is False: @@ -157,11 +158,11 @@ def main() -> None: f"{service}/FinalizeArtifact", token, { - "workflowRunBackendId": run_id, - "workflowJobRunBackendId": job_id, + "workflow_run_backend_id": run_id, + "workflow_job_run_backend_id": job_id, "name": name, "size": str(len(payload)), - "hash": {"value": f"sha256:{digest}"}, + "hash": f"sha256:{digest}", }, ) if finalized.get("ok") is False: From b0e204456744c705e8cf5e196d9dd8ac61a2d40e Mon Sep 17 00:00:00 2001 From: DNT_OF <3174600270@qq.com> Date: Sat, 7 Nov 2026 14:55:17 +0000 Subject: [PATCH 5/5] ci: drop the internal artifact uploader Keep suite results in the job log and step summary. Downloadable TRX artifacts wait until an official artifact action is allowed. Co-authored-by: DNT_OF --- .github/actions/upload-trx/action.yml | 9 -- .github/actions/upload-trx/index.js | 13 -- .github/scripts/upload-trx.py | 174 ------------------------- .github/workflows/functional-tests.yml | 46 ++++++- README.md | 4 +- 5 files changed, 45 insertions(+), 201 deletions(-) delete mode 100644 .github/actions/upload-trx/action.yml delete mode 100644 .github/actions/upload-trx/index.js delete mode 100644 .github/scripts/upload-trx.py diff --git a/.github/actions/upload-trx/action.yml b/.github/actions/upload-trx/action.yml deleted file mode 100644 index 465b34c..0000000 --- a/.github/actions/upload-trx/action.yml +++ /dev/null @@ -1,9 +0,0 @@ -name: Upload TRX -description: Upload one suite's TRX report from this repository. -inputs: - suite: - description: Test suite name - required: true -runs: - using: node24 - main: index.js diff --git a/.github/actions/upload-trx/index.js b/.github/actions/upload-trx/index.js deleted file mode 100644 index 1f8d65a..0000000 --- a/.github/actions/upload-trx/index.js +++ /dev/null @@ -1,13 +0,0 @@ -const { spawnSync } = require('child_process') - -// The results token is injected for JavaScript actions, not for plain run steps. -process.env.SUITE = process.env.INPUT_SUITE || '' -const result = spawnSync('python3', ['.github/scripts/upload-trx.py'], { - stdio: 'inherit', - env: process.env, -}) -if (result.error) { - console.error(result.error.message) - process.exit(1) -} -process.exit(result.status === null ? 1 : result.status) diff --git a/.github/scripts/upload-trx.py b/.github/scripts/upload-trx.py deleted file mode 100644 index 11ce6d3..0000000 --- a/.github/scripts/upload-trx.py +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env python3 -"""Upload a TRX zip through the Actions results service. - -The repository policy only allows actions owned by DNTOF, so this job cannot -call actions/upload-artifact. The runner still provides ACTIONS_RUNTIME_TOKEN -and ACTIONS_RESULTS_URL for the job's own artifact storage. -""" - -import base64 -import datetime -import hashlib -import json -import os -import sys -import urllib.error -import urllib.request -import zipfile -from pathlib import Path - - -def fail(message: str) -> None: - print(message, file=sys.stderr) - raise SystemExit(1) - - -def redact(text: str) -> str: - token = os.environ.get("ACTIONS_RUNTIME_TOKEN", "") - if token: - text = text.replace(token, "[REDACTED]") - # Signed blob URLs carry a query signature. Keep only the host. - redacted = [] - for line in text.splitlines(): - if "sig=" in line or "https://" in line: - redacted.append("[redacted response line]") - else: - redacted.append(line) - return "\n".join(redacted)[:800] - - -def backend_ids(token: str) -> tuple[str, str]: - payload = token.split(".")[1] - payload += "=" * (-len(payload) % 4) - claims = json.loads(base64.urlsafe_b64decode(payload)) - for scope in str(claims.get("scp", "")).split(): - parts = scope.split(":") - if len(parts) == 3 and parts[0] == "Actions.Results": - return parts[1], parts[2] - fail("ACTIONS_RUNTIME_TOKEN does not contain an Actions.Results scope") - return "", "" - - -def request_json(url: str, token: str, body: dict) -> dict: - data = json.dumps(body).encode() - req = urllib.request.Request( - url, - data=data, - method="POST", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "User-Agent": "sldataapi-trx-upload", - }, - ) - try: - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read().decode()) - except urllib.error.HTTPError as exc: - fail(f"{url.rsplit('/', 1)[-1]} HTTP {exc.code}: {redact(exc.read().decode('utf-8', 'replace'))}") - return {} - - -def find_signed_url(value: object) -> str: - if isinstance(value, dict): - for key, item in value.items(): - normalized = key.replace("_", "").lower() - if normalized in {"signeduploadurl", "signedurl"} and isinstance(item, str) and item.startswith("https://"): - return item - found = find_signed_url(item) - if found: - return found - elif isinstance(value, list): - for item in value: - found = find_signed_url(item) - if found: - return found - return "" - - -def main() -> None: - suite = os.environ.get("SUITE", "") - if not suite: - fail("SUITE is empty") - source = Path("TestResults") / suite - files = sorted(source.glob("*.trx")) if source.is_dir() else [] - if not files: - print(f"warning: no TRX files in {source}") - return - - token = os.environ.get("ACTIONS_RUNTIME_TOKEN", "") - results = os.environ.get("ACTIONS_RESULTS_URL", "") - if not token or not results: - names = sorted(name for name in os.environ if name.startswith("ACTIONS_")) - fail( - "ACTIONS_RUNTIME_TOKEN or ACTIONS_RESULTS_URL is missing. " - f"ACTIONS_* names present: {', '.join(names) if names else '(none)'}" - ) - run_id, job_id = backend_ids(token) - origin = results.split("/twirp/")[0].rstrip("/") - if "://" not in origin: - fail("ACTIONS_RESULTS_URL is not a URL") - - archive = Path(os.environ.get("RUNNER_TEMP", "/tmp")) / f"{suite}.trx.zip" - with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for path in files: - zf.write(path, arcname=path.name) - payload = archive.read_bytes() - digest = hashlib.sha256(payload).hexdigest() - expires = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=14)).strftime("%Y-%m-%dT%H:%M:%SZ") - name = f"test-results-{suite}" - service = f"{origin}/twirp/github.actions.results.api.v1.ArtifactService" - # protobuf-ts sends proto field names. Wrapper fields are plain JSON strings. - created = request_json( - f"{service}/CreateArtifact", - token, - { - "workflow_run_backend_id": run_id, - "workflow_job_run_backend_id": job_id, - "name": name, - "version": 7, - "mime_type": "application/zip", - "expires_at": expires, - }, - ) - if created.get("ok") is False: - fail(f"CreateArtifact was not accepted: {redact(json.dumps(created))}") - upload_url = find_signed_url(created) - if not upload_url: - fail(f"CreateArtifact response did not include an upload URL. Keys: {list(created)}") - - put = urllib.request.Request( - upload_url, - data=payload, - method="PUT", - headers={ - "Content-Type": "application/zip", - "x-ms-blob-type": "BlockBlob", - }, - ) - try: - with urllib.request.urlopen(put) as resp: - status = resp.status - except urllib.error.HTTPError as exc: - fail(f"blob upload HTTP {exc.code}: {redact(exc.read().decode('utf-8', 'replace'))}") - if status not in {200, 201}: - fail(f"blob upload HTTP {status}") - - finalized = request_json( - f"{service}/FinalizeArtifact", - token, - { - "workflow_run_backend_id": run_id, - "workflow_job_run_backend_id": job_id, - "name": name, - "size": str(len(payload)), - "hash": f"sha256:{digest}", - }, - ) - if finalized.get("ok") is False: - fail(f"FinalizeArtifact was not accepted: {redact(json.dumps({k: v for k, v in finalized.items() if 'url' not in k.lower()}))}") - print(f"uploaded {name} ({len(files)} trx, {len(payload)} bytes)") - - -if __name__ == "__main__": - main() diff --git a/.github/workflows/functional-tests.yml b/.github/workflows/functional-tests.yml index b937e4d..1829f57 100644 --- a/.github/workflows/functional-tests.yml +++ b/.github/workflows/functional-tests.yml @@ -1,6 +1,7 @@ # 仅运行独立逻辑测试,不构建或发布 SLDataAPI.dll。 # 不下载游戏程序集、LabAPI、Unity,不使用签名密钥或真实 token。 # 本仓库的 Actions 策略只允许 DNTOF 拥有的 action,因此这里不用 actions/*。 +# 可下载的 TRX 制品要等所有者允许官方 artifact action 之后才能保留。 name: 功能逻辑测试 on: @@ -60,8 +61,45 @@ jobs: SUITE: ${{ matrix.suite }} run: dotnet test "tests/SLDataAPI.${SUITE}.Tests/SLDataAPI.${SUITE}.Tests.csproj" --configuration Release --no-restore --logger "trx;LogFileName=${SUITE}.trx" --results-directory "TestResults/${SUITE}" - - name: 保存测试报告 + - name: 写入步骤摘要 if: ${{ !cancelled() }} - uses: ./.github/actions/upload-trx - with: - suite: ${{ matrix.suite }} + env: + SUITE: ${{ matrix.suite }} + run: | + set -euo pipefail + python3 - << 'PY' + import os + import xml.etree.ElementTree as ET + from pathlib import Path + + suite = os.environ["SUITE"] + reports = sorted(Path("TestResults", suite).glob("*.trx")) + lines = [f"### {suite}", ""] + if not reports: + lines.append("这次没有生成 TRX。结果以测试步骤的控制台输出为准。") + else: + root = ET.parse(reports[0]).getroot() + counters = next((el for el in root.iter() if el.tag.endswith("Counters")), None) + if counters is None: + raise SystemExit(f"{reports[0]} has no Counters element") + passed = int(counters.attrib.get("passed", "0")) + failed = int(counters.attrib.get("failed", "0")) + int(counters.attrib.get("error", "0")) + skipped = int(counters.attrib.get("notExecuted", "0")) + total = int(counters.attrib.get("total", "0")) + lines.extend( + [ + f"- Passed: {passed}", + f"- Failed: {failed}", + f"- Skipped: {skipped}", + f"- Total: {total}", + ] + ) + lines.extend( + [ + "", + "可下载的 TRX 制品目前不可用。仓库只允许 DNTOF 拥有的 action;在所有者允许官方 artifact action 之前,结果只留在作业日志和本摘要中。", + ] + ) + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") + PY diff --git a/README.md b/README.md index f8cf0ef..741990c 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,9 @@ dotnet test tests/SLDataAPI.Update.Tests/SLDataAPI.Update.Tests.csproj -c Releas dotnet test tests/SLDataAPI.WebDav.Tests/SLDataAPI.WebDav.Tests.csproj -c Release ``` -推送、Pull Request 和手动 `workflow_dispatch` 会走 `.github/workflows/functional-tests.yml`:在 GitHub 托管的 `ubuntu-24.04` 上分别跑这四套。任一失败会使该检查失败,其余套件仍会跑完,并保留 TRX 报告。仓库的 Actions 策略只允许 DNTOF 拥有的 action,所以这条工作流用 git 检出、官方 `dotnet-install.sh` 安装 .NET 8,不调用其他组织的 action。 +推送、Pull Request 和手动 `workflow_dispatch` 会走 `.github/workflows/functional-tests.yml`:在 GitHub 托管的 `ubuntu-24.04` 上分别跑这四套。任一失败会使该检查失败,其余套件仍会跑完。控制台日志和作业摘要里有每套的通过 / 失败 / 跳过数量。 + +仓库的 Actions 策略只允许 DNTOF 拥有的 action,所以这条工作流用 git 检出、官方 `dotnet-install.sh` 安装 .NET 8,不调用其他组织的 action。可下载的 TRX 制品目前不可用,要等所有者允许官方的 artifact action 之后才能保留。 ---