Compare commits
248 commits
Author | SHA1 | Date | |
---|---|---|---|
643cda2683 |
|||
c911af085e |
|||
43e15ca849 |
|||
e13bfa2765 |
|||
1367ed9ff1 |
|||
d2253e857e | |||
d561fcf730 |
|||
ca9bc34d39 |
|||
f58439aa1a |
|||
|
97852ced95 | ||
|
a970d8cea7 | ||
|
aeebe74ddd | ||
|
de2e2fc7b1 | ||
|
dfa1ab54e8 | ||
9a270df19e |
|||
|
2c2893da21 | ||
|
0c49fe751a | ||
|
4042ded5ae | ||
|
a88977af94 | ||
|
558ce7daa8 | ||
|
c9a6d6786f | ||
|
db0a4fd5d4 | ||
|
db790e0811 | ||
|
c2ee6cd849 | ||
|
8417ea34a0 | ||
|
13ee5cc245 | ||
|
5b138fae11 | ||
|
1ce03ca19f | ||
|
cd73f4828f | ||
|
b3cc2b53dc | ||
|
55621fbbea | ||
|
d778789959 | ||
|
abe8f92ab8 | ||
|
2a2a8e0b31 | ||
|
c90116c9ba | ||
|
8e295a96de | ||
|
e87b71cc0d | ||
|
f6b0e06dc1 | ||
|
d28f9790b8 | ||
|
98ba21e797 | ||
|
3244de48fc | ||
|
1e26d04b06 | ||
|
7d9827f9fc | ||
|
f48c570215 | ||
|
b24136ec59 | ||
|
8e9c5aad48 | ||
|
5e83ab5106 | ||
|
94c589c209 | ||
|
f43f06c334 | ||
|
b15c4b009a | ||
|
5f517ae6c7 | ||
|
6fc7263f59 | ||
|
e9d1226390 | ||
e20f6fb92e |
|||
|
1f9847b3ed | ||
|
ab3479f0d1 | ||
|
5fd14aada8 | ||
|
05f20f44ac | ||
|
389dd6f536 | ||
|
c99dfa8091 | ||
|
81c852126c | ||
|
8158497a73 | ||
|
52316093cd | ||
|
4cf0084e24 | ||
|
dffcd16a60 | ||
|
cab58d284e | ||
|
e37000143e | ||
|
b35cfe3f4f | ||
|
79623d9bc2 | ||
|
0b9498c541 | ||
|
34975924b1 | ||
|
a695cef57d | ||
|
c05eb562a9 | ||
|
8cb636ccc9 | ||
|
dd809ce3f3 | ||
|
44cc0c1010 | ||
|
a48af07d93 | ||
|
39a76eaa33 | ||
|
1f00bbfac5 | ||
|
8f89fc9953 | ||
|
a406bffabe | ||
|
3bda063578 | ||
|
2a1ebd7b85 | ||
|
d45e8e63c9 | ||
|
72e20d413e | ||
|
5aec8d677f | ||
|
b2070ec460 | ||
|
613815aa1e | ||
|
c82bccca90 | ||
|
1dc4fe225c | ||
|
e5b9105da1 | ||
|
2df196a2e6 | ||
|
cbd304c841 | ||
|
0b3c0325a5 | ||
|
600d18d05b | ||
|
f1d9897e83 | ||
|
992f995441 | ||
|
2e46d37950 | ||
|
9592da3d6e | ||
|
4f4716cdd8 | ||
68fec14155 |
|||
|
5da4074bb9 | ||
|
51f098f4ad | ||
|
a2dc40f444 | ||
|
e63baec249 | ||
|
6ca7088b9c | ||
|
8309901a8c | ||
|
7cefbd4a67 | ||
|
a3f028f2ad | ||
|
7c684eb565 | ||
|
e436f77c3e | ||
|
2ed0cdc4a3 | ||
|
484f113646 | ||
|
23675124bd | ||
|
01c494c277 | ||
|
6f3544e3d8 | ||
89d22496d7 |
|||
|
72d0cde370 | ||
|
dbcee4945c | ||
|
ca0734d470 | ||
|
923f97f8e9 | ||
|
82ca6dd6bf | ||
|
7a92941452 | ||
|
6619ef60e4 | ||
|
de4459f646 | ||
|
61f65e54db | ||
|
1d03f5b4b0 | ||
|
4eb2b22a8f | ||
|
a48e23bece | ||
|
f8a95c82e4 | ||
|
3f719ac939 | ||
|
97b9610f30 | ||
|
03597d10e5 | ||
|
f8c9d6f7cb | ||
|
22d5626bf0 | ||
|
73959c00f2 | ||
|
78179fd737 | ||
|
980e5968ea | ||
|
a88a3c6103 | ||
|
3cef317576 | ||
|
63978d79f9 | ||
|
4b3639aea7 | ||
|
8073e90f68 | ||
|
24171c9800 | ||
|
0c2b39a68a | ||
|
cc6a495998 | ||
|
ed86f30cf4 | ||
|
f9698b5a7f | ||
|
6b7d107387 | ||
|
ca174a3aa2 | ||
|
b9d0a29741 | ||
|
01e6a9c8ad | ||
|
7c2a97666d | ||
|
db67487abd | ||
83ae356b90 |
|||
|
7e0aaa8362 | ||
756a86eda1 |
|||
|
df805bfe83 | ||
60e6e6216f |
|||
|
a7160fadde |
||
|
3ae7f8813e | ||
|
642930d397 | ||
|
057a365a0e | ||
|
b67b1730b1 | ||
|
a5d49733fd | ||
|
c5c9f85e4b | ||
|
88c635cbad | ||
|
1a5c056204 | ||
|
c9db056791 | ||
|
5f94794d24 | ||
|
f41a92243c | ||
|
a88f1ba91c | ||
|
13213861ba | ||
|
2e0626667e | ||
c06fb22913 |
|||
|
06f9c278a8 | ||
97c61aaf9b |
|||
197eeea75b |
|||
7d2c68630e |
|||
|
9767167661 | ||
|
2525022df7 | ||
|
7ebe881546 | ||
|
94191f878d | ||
|
595a9d0f41 | ||
|
cec3aa3fc0 | ||
|
797fc0c04c | ||
|
fc3b5a1530 | ||
a7a9b137a7 |
|||
02fab65125 |
|||
3ef67f52c8 |
|||
40ae3a7f55 |
|||
f0c7881d57 |
|||
39f0019455 |
|||
0516abb8fd |
|||
3690b0244c |
|||
bdc847f5d0 |
|||
d8c3ab4f36 |
|||
09e7c1d8bd |
|||
1e36edf499 |
|||
746f29fd20 |
|||
14bceaa2c6 |
|||
6ae7520111 |
|||
f94cdcbd1f |
|||
220c3767e0 | |||
5f3160e331 |
|||
0f573de3e4 |
|||
cd31673569 |
|||
c7ba9b25b3 |
|||
0d14ef6142 |
|||
23b8101426 |
|||
d0cdbf55a3 |
|||
30838a1523 |
|||
68ad00170f |
|||
f1f0af6264 |
|||
7e016682c0 |
|||
311d3aedb9 |
|||
604d650d49 |
|||
55298b4617 |
|||
a8e173c8a9 |
|||
299f54fd58 |
|||
70219805d0 |
|||
2dbe3da892 |
|||
031814f7f4 |
|||
c6dd3d796a |
|||
b90f9d792f |
|||
09a5e5639e |
|||
20020c8f6d |
|||
5f229977d8 |
|||
277cc68af9 |
|||
445bc43c96 |
|||
194499b276 |
|||
134bdaa34b |
|||
4aaff462bb |
|||
d9f9d6edca |
|||
806a2dda9a |
|||
ec65b6d03f |
|||
06c09b2831 |
|||
8405af8fb3 |
|||
93758e3ab2 |
|||
608a9f68f4 |
|||
4ebeb4b873 |
|||
47f3ea1267 |
|||
72c0e3af69 |
|||
79ad3b9c24 |
|||
eca80aaa8e |
|||
607255931a |
|||
5a54f0a7a6 |
|||
6a0171c62a |
|
@ -1,6 +1,5 @@
|
|||
CACHE_DIR=/tmp/artifactview
|
||||
MAX_ARTIFACT_SIZE=100000000
|
||||
MAX_AGE_H=12
|
||||
NO_HTTPS=1
|
||||
# If you only want to access public repositories,
|
||||
# create a fine-grained token with Public Repositories (read-only) access
|
||||
GITHUB_TOKEN=github_pat_123456
|
||||
# GITHUB_TOKEN=github_pat_123456
|
||||
SITE_ALIASES=gh=>github.com;cb=>codeberg.org
|
||||
|
|
43
.forgejo/workflows/artifact.yaml
Normal file
|
@ -0,0 +1,43 @@
|
|||
name: Test artifact
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".forgejo/workflows/artifact.yaml"
|
||||
|
||||
jobs:
|
||||
artifact:
|
||||
runs-on: cimaster-latest
|
||||
steps:
|
||||
- name: 👁️ Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Create test artifacts
|
||||
run: |
|
||||
# A1
|
||||
mkdir -p artifacts/a1/mdbook
|
||||
cd artifacts
|
||||
git clone https://github.com/rust-lang/mdBook
|
||||
cd mdBook/test_book
|
||||
mdbook build -d ../../a1/mdbook
|
||||
cd ../..
|
||||
|
||||
cp -r ../tests/testfiles/junit ../tests/testfiles/sites/style.css ../tests/testfiles/sites/404.html ../tests/testfiles/sites/example.rs ../README.md a1
|
||||
|
||||
# A2
|
||||
git clone https://github.com/SveltePress/sveltepress
|
||||
cd sveltepress/packages/docs-site
|
||||
pnpm i
|
||||
pnpm build
|
||||
mv dist ../../../a2
|
||||
- name: Upload A1 (Example)
|
||||
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
|
||||
with:
|
||||
name: Example
|
||||
path: artifacts/a1
|
||||
|
||||
- name: Upload A2 (SveltePress)
|
||||
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
|
||||
with:
|
||||
name: SveltePress
|
||||
path: artifacts/a2
|
|
@ -1,6 +1,8 @@
|
|||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
@ -16,53 +18,21 @@ jobs:
|
|||
- name: 📎 Clippy
|
||||
run: cargo clippy --all -- -D warnings
|
||||
- name: 🧪 Test
|
||||
run: cargo test
|
||||
|
||||
release:
|
||||
runs-on: cimaster-latest
|
||||
needs: test
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
steps:
|
||||
- name: 👁️ Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # important to fetch tag logs
|
||||
|
||||
- name: ⚒️ Build application
|
||||
run: |
|
||||
PKG_CONFIG_SYSROOT_DIR=/usr/x86_64-linux-gnu cargo build --release --target x86_64-unknown-linux-gnu
|
||||
PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu cargo build --release --target aarch64-unknown-linux-gnu
|
||||
- name: 🐋 Build docker image
|
||||
uses: https://code.thetadev.de/ThetaDev/action-kaniko@v1
|
||||
./tests/testfiles/sites/make_zip.sh
|
||||
cargo nextest run --config-file ~/.config/nextest.toml --profile ci --workspace
|
||||
- name: 💌 Upload test report
|
||||
if: always()
|
||||
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
|
||||
with:
|
||||
image: thetadev256/artifactview
|
||||
username: thetadev256
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
tag_with_latest: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
platforms: "linux/amd64,linux/arm64"
|
||||
|
||||
- name: Prepare release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
name: test
|
||||
path: target/nextest/ci/junit.xml
|
||||
- name: 🔗 Artifactview PR comment
|
||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||
run: |
|
||||
mkdir dist
|
||||
tar -cJf dist/artifactview-x86_64-${{ github.ref_name }}.tar.xz -C target/x86_64-unknown-linux-gnu/release artifactview
|
||||
tar -cJf dist/artifactview-aarch64-${{ github.ref_name }}.tar.xz -C target/aarch64-unknown-linux-gnu/release artifactview
|
||||
if [[ "$GITEA_ACTIONS" == "true" ]]; then RUN_NUMBER="$GITHUB_RUN_NUMBER"; else RUN_NUMBER="$GITHUB_RUN_ID"; fi
|
||||
echo "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$RUN_NUMBER"
|
||||
echo "Pull: ${{ github.event.number }}"
|
||||
|
||||
{
|
||||
echo 'CHANGELOG<<END_OF_FILE'
|
||||
git show -s --format=%N "${{ github.ref }}" | tail -n +4 | awk 'BEGIN{RS="-----BEGIN PGP SIGNATURE-----"} NR==1{printf $0}'
|
||||
echo END_OF_FILE
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: 🎉 Publish release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
uses: https://gitea.com/actions/release-action@main
|
||||
with:
|
||||
title: "artifactview ${{ github.ref_name }}"
|
||||
body: "${{ env.CHANGELOG }}"
|
||||
files: dist/*
|
||||
|
||||
- name: 🚀 Deploy to server
|
||||
run: |
|
||||
curl -s -H "Authorization: Bearer ${{ secrets.THETADEV_DE_WATCHTOWER_TOKEN }}" https://watchtower.thetadev.de/v1/update
|
||||
curl -SsL --fail-with-body -w "\n" -X POST https://av.thetadev.de/.well-known/api/prComment -H "Content-Type: application/json" \
|
||||
--data '{"url": "'"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$RUN_NUMBER"'", "pr": ${{ github.event.number }}, "artifact_titles": {"test":"🧪 Test report"}, "artifact_paths": {"test":"/junit.xml?viewer=1"}}'
|
||||
|
|
23
.forgejo/workflows/docker-readme.yaml
Normal file
|
@ -0,0 +1,23 @@
|
|||
name: DockerHub README
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "README.md"
|
||||
- ".forgejo/workflows/docker-readme.yaml"
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: cimaster-latest
|
||||
steps:
|
||||
- name: 👁️ Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker Hub Description
|
||||
uses: https://github.com/peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: thetadev256
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
repository: thetadev256/artifactview
|
||||
enable-url-completion: true
|
75
.forgejo/workflows/release.yaml
Normal file
|
@ -0,0 +1,75 @@
|
|||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: cimaster-latest
|
||||
steps:
|
||||
- name: 👁️ Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: ⚒️ Build application
|
||||
run: |
|
||||
PKG_CONFIG_SYSROOT_DIR=/usr/x86_64-linux-gnu cargo build --release --target x86_64-unknown-linux-gnu
|
||||
PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu cargo build --release --target aarch64-unknown-linux-gnu
|
||||
|
||||
ln -s ./x86_64-unknown-linux-gnu target/amd64
|
||||
ln -s ./aarch64-unknown-linux-gnu target/arm64
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: https://github.com/docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Container Registry
|
||||
uses: https://github.com/docker/login-action@v3
|
||||
with:
|
||||
username: thetadev256
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: https://github.com/docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
thetadev256/artifactview
|
||||
tags: |
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=semver,pattern=v{{major}}
|
||||
|
||||
- name: 🐋 Build docker image
|
||||
uses: https://github.com/docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: "."
|
||||
file: Dockerfile
|
||||
|
||||
- name: Prepare release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
mkdir dist
|
||||
tar -cJf dist/artifactview-x86_64-${{ github.ref_name }}.tar.xz -C target/x86_64-unknown-linux-gnu/release artifactview
|
||||
tar -cJf dist/artifactview-aarch64-${{ github.ref_name }}.tar.xz -C target/aarch64-unknown-linux-gnu/release artifactview
|
||||
|
||||
{
|
||||
echo 'CHANGELOG<<END_OF_FILE'
|
||||
awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' CHANGELOG.md
|
||||
echo END_OF_FILE
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: 🎉 Publish release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
uses: https://gitea.com/actions/release-action@main
|
||||
with:
|
||||
title: "artifactview ${{ github.ref_name }}"
|
||||
body: "${{ env.CHANGELOG }}"
|
||||
files: dist/*
|
||||
|
||||
- name: 🚀 Deploy to server
|
||||
run: |
|
||||
curl -SsL --fail-with-body -H "Authorization: Bearer ${{ secrets.THETADEV_DE_WATCHTOWER_TOKEN }}" https://watchtower.thetadev.de/v1/update
|
63
.forgejo/workflows/renovate.yaml.bak
Normal file
|
@ -0,0 +1,63 @@
|
|||
name: renovate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- ".forgejo/workflows/renovate.yaml"
|
||||
- "renovate.json"
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
RENOVATE_REPOSITORIES: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: renovate/renovate:39
|
||||
|
||||
steps:
|
||||
- name: Load renovate repo cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
.tmp/cache/renovate/repository
|
||||
.tmp/cache/renovate/renovate-cache-sqlite
|
||||
.tmp/osv
|
||||
key: repo-cache-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
repo-cache-
|
||||
|
||||
- name: Run renovate
|
||||
run: renovate
|
||||
env:
|
||||
LOG_LEVEL: info
|
||||
RENOVATE_BASE_DIR: ${{ github.workspace }}/.tmp
|
||||
RENOVATE_ENDPOINT: ${{ github.server_url }}
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_REPOSITORY_CACHE: 'enabled'
|
||||
RENOVATE_TOKEN: ${{ secrets.FORGEJO_CI_BOT_TOKEN }}
|
||||
GITHUB_COM_TOKEN: ${{ secrets.GH_PUBLIC_TOKEN }}
|
||||
RENOVATE_GIT_AUTHOR: 'Renovate Bot <forgejo-renovate-action@forgejo.org>'
|
||||
|
||||
RENOVATE_X_SQLITE_PACKAGE_CACHE: true
|
||||
|
||||
GIT_AUTHOR_NAME: 'Renovate Bot'
|
||||
GIT_AUTHOR_EMAIL: 'forgejo-renovate-action@forgejo.org'
|
||||
GIT_COMMITTER_NAME: 'Renovate Bot'
|
||||
GIT_COMMITTER_EMAIL: 'forgejo-renovate-action@forgejo.org'
|
||||
|
||||
OSV_OFFLINE_ROOT_DIR: ${{ github.workspace }}/.tmp/osv
|
||||
|
||||
- name: Save renovate repo cache
|
||||
if: always() && env.RENOVATE_DRY_RUN != 'full'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: |
|
||||
.tmp/cache/renovate/repository
|
||||
.tmp/cache/renovate/renovate-cache-sqlite
|
||||
.tmp/osv
|
||||
key: repo-cache-${{ github.run_id }}
|
2
.gitignore
vendored
|
@ -1,3 +1,5 @@
|
|||
/target
|
||||
/dist
|
||||
/.env
|
||||
*.snap.new
|
||||
/tests/testfiles/sites_data
|
||||
|
|
|
@ -10,3 +10,11 @@ repos:
|
|||
- id: cargo-fmt
|
||||
- id: cargo-clippy
|
||||
args: ["--all", "--tests", "--", "-D", "warnings"]
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: compress-res
|
||||
name: Compress resources
|
||||
language: system
|
||||
entry: zopfli
|
||||
files: "^resources/.+.css$"
|
||||
|
|
328
CHANGELOG.md
|
@ -3,6 +3,334 @@
|
|||
All notable changes to this project will be documented in this file.
|
||||
|
||||
|
||||
## [v0.4.9](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.8..v0.4.9) - 2025-07-20
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- TARGETARCH argument Buildkit workflow - ([c911af0](https://codeberg.org/ThetaDev/artifactview/commit/c911af085eb4e434f9976a9573d19e8c05985d3c))
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(deps)* Update rust crate axum-test to v17.2.0 (#171) - ([dfa1ab5](https://codeberg.org/ThetaDev/artifactview/commit/dfa1ab54e8fd72113ed25d0a4a7f368db27fb2d0))
|
||||
- *(deps)* Update rust crate serde_json to v1.0.138 (#173) - ([de2e2fc](https://codeberg.org/ThetaDev/artifactview/commit/de2e2fc7b174b2cbe8440c3a17d8002d6edbba73))
|
||||
- *(deps)* Pin dependencies (#174) - ([aeebe74](https://codeberg.org/ThetaDev/artifactview/commit/aeebe74ddd07e1d8323f495a5065b8a9f06c2bc9))
|
||||
- *(deps)* Update rust crate env_logger to v0.11.6 (#175) - ([a970d8c](https://codeberg.org/ThetaDev/artifactview/commit/a970d8cea779690e150ddbcc416d26a2efe4a606))
|
||||
- *(deps)* Update rust crate zip to v2.2.2 (#181) - ([97852ce](https://codeberg.org/ThetaDev/artifactview/commit/97852ced9557b354642a703117b2a853e8f0c626))
|
||||
- Update dependencies - ([1367ed9](https://codeberg.org/ThetaDev/artifactview/commit/1367ed9ff1dd3e2ca142fab14c1846a2a6b46423))
|
||||
- Update release workflow to use Buildkit - ([e13bfa2](https://codeberg.org/ThetaDev/artifactview/commit/e13bfa2765b2bef0370efb01ba828cdf9a0da7b3))
|
||||
- Fix clippy lints - ([43e15ca](https://codeberg.org/ThetaDev/artifactview/commit/43e15ca849be79905af0c517507bc3bdd0e995bb))
|
||||
|
||||
|
||||
## [v0.4.8](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.7..v0.4.8) - 2025-01-26
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Lifetime-related lints - ([e20f6fb](https://codeberg.org/ThetaDev/artifactview/commit/e20f6fb92e86751222d2c5143ee384cdbea1159d))
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(deps)* Update rust crate async-compression to v0.4.15 (#85) - ([4f4716c](https://codeberg.org/ThetaDev/artifactview/commit/4f4716cdd86c317ede2b381d375ef8e736aee240))
|
||||
- *(deps)* Update rust crate async-compression to v0.4.16 (#86) - ([9592da3](https://codeberg.org/ThetaDev/artifactview/commit/9592da3d6e2c2223174fcc459e94f29bf5067ead))
|
||||
- *(deps)* Update rust crate serde_json to v1.0.129 (#87) - ([2e46d37](https://codeberg.org/ThetaDev/artifactview/commit/2e46d3795089ef7b2739db4d216f4db99f792071))
|
||||
- *(deps)* Update rust crate serde_json to v1.0.131 (#88) - ([992f995](https://codeberg.org/ThetaDev/artifactview/commit/992f9954414af550fce90c9c7424ab0da2296875))
|
||||
- *(deps)* Update rust crate governor to v0.6.4 (#89) - ([f1d9897](https://codeberg.org/ThetaDev/artifactview/commit/f1d9897e832b5cdb99fd81edcb38d27bd6b445f8))
|
||||
- *(deps)* Update rust crate serde_json to v1.0.132 (#90) - ([600d18d](https://codeberg.org/ThetaDev/artifactview/commit/600d18d05b356c641a32b93b2407a1f23e2370c4))
|
||||
- *(deps)* Update rust crate async-compression to v0.4.17 (#91) - ([0b3c032](https://codeberg.org/ThetaDev/artifactview/commit/0b3c0325a50cd456cb9b62f97e916f0760785a3c))
|
||||
- *(deps)* Update rust crate governor to 0.7.0 (#92) - ([cbd304c](https://codeberg.org/ThetaDev/artifactview/commit/cbd304c84195983596156a95c26d65a89e93df8a))
|
||||
- *(deps)* Update rust crate thiserror to v1.0.65 (#94) - ([2df196a](https://codeberg.org/ThetaDev/artifactview/commit/2df196a2e666e8186d9ff66e885123c8a48c743c))
|
||||
- *(deps)* Update rust crate serde to v1.0.213 (#93) - ([e5b9105](https://codeberg.org/ThetaDev/artifactview/commit/e5b9105da1fb0c63584cc409b5dd98c1fe045f9b))
|
||||
- *(deps)* Update rust crate tokio to v1.41.0 (#95) - ([1dc4fe2](https://codeberg.org/ThetaDev/artifactview/commit/1dc4fe225c01f237f6a698fced7eff72cfb8ee85))
|
||||
- *(deps)* Update rust crate pin-project to v1.1.7 (#96) - ([c82bccc](https://codeberg.org/ThetaDev/artifactview/commit/c82bccca9098281fa42ded5d5527eadb3cbcce88))
|
||||
- *(deps)* Update rust crate regex to v1.11.1 (#97) - ([613815a](https://codeberg.org/ThetaDev/artifactview/commit/613815aa1ebc01643e117ca9fafcb37bfe7d29d7))
|
||||
- *(deps)* Update rust crate axum-test to v16.3.0 (#98) - ([b2070ec](https://codeberg.org/ThetaDev/artifactview/commit/b2070ec460e45eeb9997885bd7cf54913f9f4183))
|
||||
- *(deps)* Update rust crate insta to v1.41.0 (#99) - ([5aec8d6](https://codeberg.org/ThetaDev/artifactview/commit/5aec8d677f089ff5092fe78655170d52e544baeb))
|
||||
- *(deps)* Update rust crate quick-xml to 0.37.0 (#100) - ([72e20d4](https://codeberg.org/ThetaDev/artifactview/commit/72e20d413e80b86d49ea0b275ce6aece99d75314))
|
||||
- *(deps)* Update rust crate reqwest to v0.12.9 (#101) - ([d45e8e6](https://codeberg.org/ThetaDev/artifactview/commit/d45e8e63c9ce4067a05839643581b9f42e4048ec))
|
||||
- *(deps)* Update rust crate serde to v1.0.214 (#102) - ([2a1ebd7](https://codeberg.org/ThetaDev/artifactview/commit/2a1ebd7b854ca82cceb768202e4d9ee984007311))
|
||||
- *(deps)* Update rust crate futures-lite to v2.4.0 (#103) - ([3bda063](https://codeberg.org/ThetaDev/artifactview/commit/3bda06357809e68c0804aa425e625893af1bbde2))
|
||||
- *(deps)* Update rust crate insta to v1.41.1 (#104) - ([a406bff](https://codeberg.org/ThetaDev/artifactview/commit/a406bffabeeb2627cd5ef74f3520add6eb0a8d6c))
|
||||
- *(deps)* Update rust crate thiserror to v1.0.66 (#105) - ([8f89fc9](https://codeberg.org/ThetaDev/artifactview/commit/8f89fc9953370c4325c63762e4451d37a1a4a64a))
|
||||
- *(deps)* Update rust crate scraper to 0.21.0 (#106) - ([1f00bbf](https://codeberg.org/ThetaDev/artifactview/commit/1f00bbfac53521cf0f76cf6fc4bf23e7a5e10562))
|
||||
- *(deps)* Update rust crate thiserror to v1.0.67 (#107) - ([39a76ea](https://codeberg.org/ThetaDev/artifactview/commit/39a76eaa334d3f57e30e3ba95eb781dfa7aee1ee))
|
||||
- *(deps)* Update rust crate thiserror to v1.0.68 (#108) - ([a48af07](https://codeberg.org/ThetaDev/artifactview/commit/a48af07d936a77977d602649fe579941b2cd2630))
|
||||
- *(deps)* Update rust crate url to v2.5.3 (#109) - ([44cc0c1](https://codeberg.org/ThetaDev/artifactview/commit/44cc0c10103eb8fc10ddcc17e559479fadcbe4f1))
|
||||
- *(deps)* Update rust crate tokio to v1.41.1 (#111) - ([dd809ce](https://codeberg.org/ThetaDev/artifactview/commit/dd809ce3f322fe8dafadeb802be7fcf905aa2f02))
|
||||
- *(deps)* Update rust crate thiserror to v2 (#110) - ([8cb636c](https://codeberg.org/ThetaDev/artifactview/commit/8cb636ccc9c93800e6e98522de5c38bb05e76fdb))
|
||||
- *(deps)* Update rust crate futures-lite to v2.5.0 (#112) - ([c05eb56](https://codeberg.org/ThetaDev/artifactview/commit/c05eb562a9d10945ee534ef50208aeb004023c51))
|
||||
- *(deps)* Update rust crate thiserror to v2.0.3 (#113) - ([a695cef](https://codeberg.org/ThetaDev/artifactview/commit/a695cef57d5c73492d806958290bb37bd9613125))
|
||||
- *(deps)* Update rust crate serde to v1.0.215 (#114) - ([3497592](https://codeberg.org/ThetaDev/artifactview/commit/34975924b1e1c3d5367346a1e0274a435496091f))
|
||||
- *(deps)* Update rust crate flate2 to v1.0.35 (#115) - ([0b9498c](https://codeberg.org/ThetaDev/artifactview/commit/0b9498c541c0188fd7c82a99b2f778251b831df3))
|
||||
- *(deps)* Update rust crate axum to v0.7.8 (#116) - ([79623d9](https://codeberg.org/ThetaDev/artifactview/commit/79623d9bc2d40e43374c4a2934d6bbac1235bae7))
|
||||
- *(deps)* Update rust crate axum-extra to v0.9.5 (#117) - ([b35cfe3](https://codeberg.org/ThetaDev/artifactview/commit/b35cfe3f4fe402943ee3238b671c18449c5437ba))
|
||||
- *(deps)* Update rust crate axum-test to v16.4.0 (#118) - ([e370001](https://codeberg.org/ThetaDev/artifactview/commit/e37000143e752b4bc496797de2410be33d3adf2b))
|
||||
- *(deps)* Update rust crate axum to v0.7.9 (#119) - ([cab58d2](https://codeberg.org/ThetaDev/artifactview/commit/cab58d284e6b9f2e6b730d9b3f6d648d0955832d))
|
||||
- *(deps)* Update rust crate quick-xml to v0.37.1 (#121) - ([dffcd16](https://codeberg.org/ThetaDev/artifactview/commit/dffcd16a60b456e9ed547b2a01342df3585e607f))
|
||||
- *(deps)* Update rust crate axum-extra to v0.9.6 (#120) - ([4cf0084](https://codeberg.org/ThetaDev/artifactview/commit/4cf0084e24969d15149468de42cf6ed15e1169f5))
|
||||
- *(deps)* Update rust crate serde_json to v1.0.133 (#122) - ([5231609](https://codeberg.org/ThetaDev/artifactview/commit/52316093cdad4ceca274e4c65035842e0413892e))
|
||||
- *(deps)* Update rust crate tower-http to v0.6.2 (#123) - ([8158497](https://codeberg.org/ThetaDev/artifactview/commit/8158497a73367fb2280d48350e3afa868c006d65))
|
||||
- *(deps)* Update rust crate zip to v2.2.1 (#124) - ([81c8521](https://codeberg.org/ThetaDev/artifactview/commit/81c852126ca45172bd00f75a5007263fefb5967b))
|
||||
- *(deps)* Update rust crate url to v2.5.4 (#125) - ([c99dfa8](https://codeberg.org/ThetaDev/artifactview/commit/c99dfa809175e84261245b2680a964b95d81b4e3))
|
||||
- *(deps)* Update rust crate comrak to 0.30.0 (#126) - ([389dd6f](https://codeberg.org/ThetaDev/artifactview/commit/389dd6f536b9044a8b9675c3ad34fa218028d154))
|
||||
- *(deps)* Update rust crate async-compression to v0.4.18 (#127) - ([05f20f4](https://codeberg.org/ThetaDev/artifactview/commit/05f20f44ac4910e600f318d656d376d52fd6b131))
|
||||
- *(deps)* Update rust crate comrak to 0.31.0 (#128) - ([5fd14aa](https://codeberg.org/ThetaDev/artifactview/commit/5fd14aada8f310fdeb0b2dc33a15de0b195ebc81))
|
||||
- *(deps)* Update rust crate tracing to v0.1.41 (#129) - ([ab3479f](https://codeberg.org/ThetaDev/artifactview/commit/ab3479f0d1b3a30413df654cc9f2e7a52081b542))
|
||||
- *(deps)* Update rust crate tracing-subscriber to v0.3.19 (#130) - ([1f9847b](https://codeberg.org/ThetaDev/artifactview/commit/1f9847b3edd73c1e905feb88087ee4325364ddda))
|
||||
- *(deps)* Update rust crate thiserror to v2.0.4 (#131) - ([e9d1226](https://codeberg.org/ThetaDev/artifactview/commit/e9d122639022d3b5b68b4626e00442a81acf85f5))
|
||||
- *(deps)* Update rust crate time to v0.3.37 (#132) - ([6fc7263](https://codeberg.org/ThetaDev/artifactview/commit/6fc7263f5996abefdb71fd5ecae277ec707cbdd9))
|
||||
- *(deps)* Update rust crate http to v1.2.0 (#133) - ([5f517ae](https://codeberg.org/ThetaDev/artifactview/commit/5f517ae6c784e4fbc99ca69650d85621e90f776c))
|
||||
- *(deps)* Update rust crate tokio to v1.42.0 (#134) - ([b15c4b0](https://codeberg.org/ThetaDev/artifactview/commit/b15c4b009a268cd4227846f166131aad5628f87d))
|
||||
- *(deps)* Update rust crate tokio-util to v0.7.13 (#135) - ([f43f06c](https://codeberg.org/ThetaDev/artifactview/commit/f43f06c33462c15a8a752493f14c86d9dbde0e8d))
|
||||
- *(deps)* Update rust crate thiserror to v2.0.5 (#136) - ([94c589c](https://codeberg.org/ThetaDev/artifactview/commit/94c589c20936c8e56be3147fd01e51d8b25e617f))
|
||||
- *(deps)* Update rust crate thiserror to v2.0.6 (#137) - ([5e83ab5](https://codeberg.org/ThetaDev/artifactview/commit/5e83ab510614db696a7245ad5027c717b3493cab))
|
||||
- *(deps)* Update rust crate chrono to v0.4.39 (#138) - ([8e9c5aa](https://codeberg.org/ThetaDev/artifactview/commit/8e9c5aad48378c2fa1a1d3d370b96de2fca5dd86))
|
||||
- *(deps)* Update rust crate governor to 0.8.0 (#139) - ([b24136e](https://codeberg.org/ThetaDev/artifactview/commit/b24136ec597d2b4c579176a6a7845878549478bb))
|
||||
- *(deps)* Update rust crate scraper to 0.22.0 (#140) - ([f48c570](https://codeberg.org/ThetaDev/artifactview/commit/f48c57021505091b22c36bd49bd4527e652b8d78))
|
||||
- *(deps)* Update rust crate serde to v1.0.216 (#141) - ([7d9827f](https://codeberg.org/ThetaDev/artifactview/commit/7d9827f9fc52aa88f19efa371e36877723328248))
|
||||
- *(deps)* Update rust crate thiserror to v2.0.7 (#142) - ([1e26d04](https://codeberg.org/ThetaDev/artifactview/commit/1e26d04b068f9c2e0a65a3dbcc43f66d118a6043))
|
||||
- *(deps)* Update rust crate axum-test to v16.4.1 (#143) - ([3244de4](https://codeberg.org/ThetaDev/artifactview/commit/3244de48fc2691898599ab20f2baa888db9f3c82))
|
||||
- *(deps)* Update rust crate zip to v2.2.2 (#144) - ([98ba21e](https://codeberg.org/ThetaDev/artifactview/commit/98ba21e7979bfbdd06fb46da8e45f97f71dc2c99))
|
||||
- *(deps)* Update rust crate proptest to v1.6.0 (#145) - ([d28f979](https://codeberg.org/ThetaDev/artifactview/commit/d28f9790b825b2af749e3fcd07718faa91c2a942))
|
||||
- *(deps)* Update rust crate comrak to 0.32.0 (#146) - ([f6b0e06](https://codeberg.org/ThetaDev/artifactview/commit/f6b0e06dc1a39311c4473e0f4c3c5742a352e00f))
|
||||
- *(deps)* Update rust crate thiserror to v2.0.8 (#147) - ([e87b71c](https://codeberg.org/ThetaDev/artifactview/commit/e87b71cc0df91f0ee71a88a3bd0127d4f8c74eea))
|
||||
- *(deps)* Update rust crate env_logger to v0.11.6 (#148) - ([8e295a9](https://codeberg.org/ThetaDev/artifactview/commit/8e295a96decd532fdd854ee8950a68d496c1617d))
|
||||
- *(deps)* Update rust crate serde_json to v1.0.134 (#149) - ([c90116c](https://codeberg.org/ThetaDev/artifactview/commit/c90116c9bac801e57062a462bd7a08a5fd6389b2))
|
||||
- *(deps)* Update rust crate thiserror to v2.0.9 (#150) - ([2a2a8e0](https://codeberg.org/ThetaDev/artifactview/commit/2a2a8e0b310b4ddcf15f20b1189bf768499f6b75))
|
||||
- *(deps)* Update rust crate serde to v1.0.217 (#151) - ([abe8f92](https://codeberg.org/ThetaDev/artifactview/commit/abe8f92ab8cdee926ca1ad4faf280427a9439e0e))
|
||||
- *(deps)* Update rust crate quick-xml to v0.37.2 (#152) - ([d778789](https://codeberg.org/ThetaDev/artifactview/commit/d7787899593d60243c1efa6dd036fc44e9c51868))
|
||||
- *(deps)* Update rust crate axum-extra to 0.10.0 (#154) - ([55621fb](https://codeberg.org/ThetaDev/artifactview/commit/55621fbbea51aed2234e43fa4b5ed61524ca1805))
|
||||
- *(deps)* Update rust crate rstest to 0.24.0 (#155) - ([b3cc2b5](https://codeberg.org/ThetaDev/artifactview/commit/b3cc2b53dc032d2821748895c983dde49c56eb65))
|
||||
- *(deps)* Update rust crate reqwest to v0.12.12 (#156) - ([cd73f48](https://codeberg.org/ThetaDev/artifactview/commit/cd73f4828f67d31dee356cb98a1ac45354e0e728))
|
||||
- *(deps)* Update rust crate comrak to 0.33.0 (#158) - ([1ce03ca](https://codeberg.org/ThetaDev/artifactview/commit/1ce03ca19f4032d5e88c8efbe83b98c45d98247d))
|
||||
- *(deps)* Update rust crate insta to v1.42.0 (#159) - ([5b138fa](https://codeberg.org/ThetaDev/artifactview/commit/5b138fae112d47a08b0940d61bd403047a8567fa))
|
||||
- *(deps)* Update rust crate pin-project to v1.1.8 (#161) - ([13ee5cc](https://codeberg.org/ThetaDev/artifactview/commit/13ee5cc2456180bfdc47a2d9e3a0dfe9a2dacd7d))
|
||||
- *(deps)* Update rust crate serde_json to v1.0.135 (#162) - ([8417ea3](https://codeberg.org/ThetaDev/artifactview/commit/8417ea34a0808d2ba25194021bbe38538ce52ddf))
|
||||
- *(deps)* Update rust crate thiserror to v2.0.10 (#163) - ([c2ee6cd](https://codeberg.org/ThetaDev/artifactview/commit/c2ee6cd84933e7cb2167b4cd34ec47f926105e59))
|
||||
- *(deps)* Update rust crate tokio to v1.43.0 (#164) - ([db790e0](https://codeberg.org/ThetaDev/artifactview/commit/db790e0811e9a67a63dc4708ca928efbcff1eb49))
|
||||
- *(deps)* Update rust crate thiserror to v2.0.11 (#165) - ([db0a4fd](https://codeberg.org/ThetaDev/artifactview/commit/db0a4fd5d48842ff48a375d241139ad91796422b))
|
||||
- *(deps)* Update rust crate futures-lite to v2.6.0 (#166) - ([c9a6d67](https://codeberg.org/ThetaDev/artifactview/commit/c9a6d6786f763825874520776653b311bc7bd5d8))
|
||||
- *(deps)* Update rust crate serde_json to v1.0.137 (#167) - ([558ce7d](https://codeberg.org/ThetaDev/artifactview/commit/558ce7daa8fbedac507f2d6e01961896eb8daac0))
|
||||
- *(deps)* Update rust crate comrak to 0.34.0 (#168) - ([a88977a](https://codeberg.org/ThetaDev/artifactview/commit/a88977af942d275422670984ddbefa53f8d61e13))
|
||||
- *(deps)* Update rust crate comrak to 0.35.0 (#169) - ([4042ded](https://codeberg.org/ThetaDev/artifactview/commit/4042ded5aee3763c293ffd264cb2428eb4266845))
|
||||
- *(deps)* Update rust crate insta to v1.42.1 (#170) - ([0c49fe7](https://codeberg.org/ThetaDev/artifactview/commit/0c49fe751a833ca684bfc39d19e2f1eb7ea269f5))
|
||||
- *(deps)* Update rust crate axum to 0.8.0 (#157) - ([2c2893d](https://codeberg.org/ThetaDev/artifactview/commit/2c2893da218737572e3943e2b72f7cec4ca6798f))
|
||||
|
||||
|
||||
## [v0.4.7](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.6..v0.4.7) - 2024-10-12
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(deps)* Update rust crate serde_json to v1.0.121 (#29) - ([df805bf](https://codeberg.org/ThetaDev/artifactview/commit/df805bfe8394dd148ded1d4d3af901eb97593885))
|
||||
- *(deps)* Update rust crate serde_json to v1.0.122 (#32) - ([db67487](https://codeberg.org/ThetaDev/artifactview/commit/db67487abdfc15fe55854fcb233e0bb876b603b3))
|
||||
- *(deps)* Update rust crate regex to v1.10.6 (#33) - ([7c2a976](https://codeberg.org/ThetaDev/artifactview/commit/7c2a97666d98d4959affbb8ece93c4ba162a760d))
|
||||
- *(deps)* Update rust crate serde-env to 0.2.0 (#37) - ([6b7d107](https://codeberg.org/ThetaDev/artifactview/commit/6b7d107387ff3e52e62e4ed19c64e63f8048c478))
|
||||
- *(deps)* Update rust crate serde to v1.0.205 (#38) - ([f9698b5](https://codeberg.org/ThetaDev/artifactview/commit/f9698b5a7f9c7f3748d4d7aa38f7dc4c0f5f2029))
|
||||
- *(deps)* Update rust crate serde to v1.0.206 (#39) - ([ed86f30](https://codeberg.org/ThetaDev/artifactview/commit/ed86f30cf4a736eeb4a3d471e81b8e7f7344b53b))
|
||||
- *(deps)* Update rust crate serde_json to v1.0.124 (#40) - ([cc6a495](https://codeberg.org/ThetaDev/artifactview/commit/cc6a4959983205ae2f40d81c9a40c8514165c0bb))
|
||||
- *(deps)* Update rust crate serde to v1.0.207 (#41) - ([0c2b39a](https://codeberg.org/ThetaDev/artifactview/commit/0c2b39a68a1adb567a1582f0c1b9e024fda9ed53))
|
||||
- *(deps)* Update rust crate serde to v1.0.208 (#43) - ([8073e90](https://codeberg.org/ThetaDev/artifactview/commit/8073e90f685d80565db81e23769841c16c2af261))
|
||||
- *(deps)* Update rust crate serde_json to v1.0.125 (#44) - ([4b3639a](https://codeberg.org/ThetaDev/artifactview/commit/4b3639aea7beed4ebc421fdfe26823be164d5c1c))
|
||||
- *(deps)* Update rust crate comrak to 0.27.0 (#46) - ([3cef317](https://codeberg.org/ThetaDev/artifactview/commit/3cef3175767170824f604fcccdc912bf09745bf9))
|
||||
- *(deps)* Update rust crate comrak to 0.28.0 (#47) - ([a88a3c6](https://codeberg.org/ThetaDev/artifactview/commit/a88a3c6103e776a4d10b3f7e6e9a37a2c672cfba))
|
||||
- *(deps)* Update rust crate quick_cache to v0.6.6 (#50) - ([73959c0](https://codeberg.org/ThetaDev/artifactview/commit/73959c00f2c54b682c3db8640ca12319ce4ee37d))
|
||||
- *(deps)* Update rust crate reqwest to v0.12.7 (#51) - ([22d5626](https://codeberg.org/ThetaDev/artifactview/commit/22d5626bf025783a127cd99faa0052778e0253b1))
|
||||
- *(deps)* Update rust crate serde to v1.0.210 (#52) - ([f8c9d6f](https://codeberg.org/ThetaDev/artifactview/commit/f8c9d6f7cb475f4642f5e4f11108c4d053cc8c7e))
|
||||
- *(deps)* Update rust crate serde_json to v1.0.128 (#57) - ([a48e23b](https://codeberg.org/ThetaDev/artifactview/commit/a48e23beceefc1b4c51910dc7114ab62abfd189c))
|
||||
- *(deps)* Update rust crate quick_cache to v0.6.9 (#59) - ([4eb2b22](https://codeberg.org/ThetaDev/artifactview/commit/4eb2b22a8f1c2b1b28f72303d364708d04790eca))
|
||||
- *(deps)* Update rust crate tower-http to 0.6.0 (#61) - ([1d03f5b](https://codeberg.org/ThetaDev/artifactview/commit/1d03f5b4b09596a68893126d8b177226b62fb38a))
|
||||
- *(deps)* Update rust crate axum to v0.7.6 (#62) - ([61f65e5](https://codeberg.org/ThetaDev/artifactview/commit/61f65e54db431b3e94d21188920ae88233c44d3b))
|
||||
- *(deps)* Update rust crate quick-xml to v0.36.2 (#64) - ([de4459f](https://codeberg.org/ThetaDev/artifactview/commit/de4459f646444a949c390394524f284a1944a0da))
|
||||
- *(deps)* Update rust crate axum-extra to v0.9.4 (#63) - ([6619ef6](https://codeberg.org/ThetaDev/artifactview/commit/6619ef60e44832dd8839bcaf82d43707965b772a))
|
||||
- *(deps)* Update rust crate thiserror to v1.0.64 (#66) - ([923f97f](https://codeberg.org/ThetaDev/artifactview/commit/923f97f8e9d0855ff97685496221a180018ae686))
|
||||
- *(deps)* Update rust crate tower-http to v0.6.1 (#68) - ([ca0734d](https://codeberg.org/ThetaDev/artifactview/commit/ca0734d47072d555ba07b2f512975b9379305a58))
|
||||
- *(deps)* Update rust crate secrecy to 0.10.0 (#60) - ([72d0cde](https://codeberg.org/ThetaDev/artifactview/commit/72d0cde37075220cc6a938840ad30781ecdcbaa7))
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(deps)* Update rust crate zip to v2.1.6 (#31) - ([7e0aaa8](https://codeberg.org/ThetaDev/artifactview/commit/7e0aaa8362005b56526ed5a6114f473893a5cf46))
|
||||
- *(deps)* Update rust crate flate2 to v1.0.31 (#34) - ([01e6a9c](https://codeberg.org/ThetaDev/artifactview/commit/01e6a9c8ad1f7c7dd5307a02a4a2b3c381aeacd6))
|
||||
- *(deps)* Update rust crate rstest to 0.22.0 (#35) - ([b9d0a29](https://codeberg.org/ThetaDev/artifactview/commit/b9d0a29741138a4dd7b758417b003c9bddc35f3e))
|
||||
- *(deps)* Update rust crate scraper to 0.20.0 (#36) - ([ca174a3](https://codeberg.org/ThetaDev/artifactview/commit/ca174a3aa21d0466930c2aa3291c2ecbed2ac31f))
|
||||
- *(deps)* Update rust crate axum-test to v15.3.1 (#42) - ([24171c9](https://codeberg.org/ThetaDev/artifactview/commit/24171c9800aa52270c203e5a5fc40f520b6ac74c))
|
||||
- *(deps)* Update rust crate tokio to v1.39.3 (#45) - ([63978d7](https://codeberg.org/ThetaDev/artifactview/commit/63978d79f9c3e276e59d7f3a558cec5f8f88e17e))
|
||||
- *(deps)* Update rust crate flate2 to v1.0.33 (#48) - ([980e596](https://codeberg.org/ThetaDev/artifactview/commit/980e5968eaa909c9d7a72d78156d3465c4599abe))
|
||||
- *(deps)* Update rust crate tokio-util to v0.7.12 (#49) - ([78179fd](https://codeberg.org/ThetaDev/artifactview/commit/78179fd73791c47cd3a60a37e704472109b50c15))
|
||||
- *(deps)* Update rust crate insta to v1.40.0 (#54) - ([03597d1](https://codeberg.org/ThetaDev/artifactview/commit/03597d10e58eca8a56e4708971cac1750e7707f4))
|
||||
- *(deps)* Update rust crate tokio to v1.40.0 (#55) - ([97b9610](https://codeberg.org/ThetaDev/artifactview/commit/97b9610f308c8ad0a083f6522c8b661c4b2c1e4f))
|
||||
- *(deps)* Update rust crate zip to v2.2.0 (#56) - ([3f719ac](https://codeberg.org/ThetaDev/artifactview/commit/3f719ac939612722b82b8bad2744b4570ff40df7))
|
||||
- *(deps)* Update rust crate once_cell to v1.20.0 (#58) - ([f8a95c8](https://codeberg.org/ThetaDev/artifactview/commit/f8a95c82e4a627d10906330327e94cd829d6f4c8))
|
||||
- *(deps)* Update rust crate axum-test to v15.7.1 (#53) - ([7a92941](https://codeberg.org/ThetaDev/artifactview/commit/7a92941452b0e2f59e0ceda49a378f0ef43784cb))
|
||||
- *(deps)* Update rust crate axum-test to v15.7.3 (#65) - ([82ca6dd](https://codeberg.org/ThetaDev/artifactview/commit/82ca6dd6bff9d8cb87a349cc8edb46f42a9721fc))
|
||||
- *(deps)* Update rust crate axum-test to v16 (#69) - ([dbcee49](https://codeberg.org/ThetaDev/artifactview/commit/dbcee4945c0be57534bdd2d7d98d85767e0cf92c))
|
||||
- *(deps)* Update rust crate flate2 to v1.0.34 (#70) - ([6f3544e](https://codeberg.org/ThetaDev/artifactview/commit/6f3544e3d8051697053cad5a751df6a1b4bc658b))
|
||||
- *(deps)* Update rust crate axum to v0.7.7 (#71) - ([01c494c](https://codeberg.org/ThetaDev/artifactview/commit/01c494c2773dc67f922beed7e84aa2ff59fb575f))
|
||||
- *(deps)* Update rust crate axum-test to v16.0.1 (#72) - ([2367512](https://codeberg.org/ThetaDev/artifactview/commit/23675124bdb15e01f037a6380f97f4d2921a34eb))
|
||||
- *(deps)* Update rust crate once_cell to v1.20.1 (#73) - ([484f113](https://codeberg.org/ThetaDev/artifactview/commit/484f1136469f5459b1d464944621e9db450d2f6a))
|
||||
- *(deps)* Update rust crate axum-test to v16.1.0 (#74) - ([2ed0cdc](https://codeberg.org/ThetaDev/artifactview/commit/2ed0cdc4a372c0a40de2ddc70a14b658a3619eec))
|
||||
- *(deps)* Update rust crate regex to v1.11.0 (#75) - ([e436f77](https://codeberg.org/ThetaDev/artifactview/commit/e436f77c3e4969d83c42d45201c15f0375d90ad0))
|
||||
- *(deps)* Update rust crate rstest to 0.23.0 (#76) - ([7c684eb](https://codeberg.org/ThetaDev/artifactview/commit/7c684eb5657eda84668bf3a1aadf38f1e4ac51db))
|
||||
- *(deps)* Update rust crate reqwest to v0.12.8 (#77) - ([a3f028f](https://codeberg.org/ThetaDev/artifactview/commit/a3f028f2ad11d3e599bf20d1f92679bf8b8dafc4))
|
||||
- *(deps)* Update rust crate async-compression to v0.4.13 (#78) - ([7cefbd4](https://codeberg.org/ThetaDev/artifactview/commit/7cefbd4a67e5636f046177f35fce14fff6300cb4))
|
||||
- *(deps)* Update rust crate once_cell to v1.20.2 (#79) - ([8309901](https://codeberg.org/ThetaDev/artifactview/commit/8309901a8c658cdf948889df57b731147d77c949))
|
||||
- *(deps)* Update rust crate pin-project to v1.1.6 (#80) - ([6ca7088](https://codeberg.org/ThetaDev/artifactview/commit/6ca7088b9c7ecd001df52f9cb35e86301c231bbb))
|
||||
- *(deps)* Update rust crate axum-test to v16.2.0 (#81) - ([e63baec](https://codeberg.org/ThetaDev/artifactview/commit/e63baec2490e069953f63d158f9af212f154055b))
|
||||
- *(deps)* Update rust crate secrecy to v0.10.3 (#82) - ([a2dc40f](https://codeberg.org/ThetaDev/artifactview/commit/a2dc40f4443cb3c3d1c7cf2b9e7178777c4b73e7))
|
||||
- *(deps)* Update rust crate async-compression to v0.4.14 (#83) - ([51f098f](https://codeberg.org/ThetaDev/artifactview/commit/51f098f4ada63ddc550774a60a81eb3d55697b44))
|
||||
- *(deps)* Update rust crate comrak to 0.29.0 (#84) - ([5da4074](https://codeberg.org/ThetaDev/artifactview/commit/5da4074bb9e87c9d17ed70669b6e47a3d57b1e8f))
|
||||
|
||||
|
||||
## [v0.4.6](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.5..v0.4.6) - 2024-07-28
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(deps)* Update rust crate serde_json to v1.0.120 (#14) - ([06f9c27](https://codeberg.org/ThetaDev/artifactview/commit/06f9c278a857a272580ee1c4f8e58078556accda))
|
||||
- *(deps)* Update rust crate quick_cache to 0.6.0 (#15) - ([2e06266](https://codeberg.org/ThetaDev/artifactview/commit/2e0626667e5fc344df9870cd8c924b9dd60886bb))
|
||||
- *(deps)* Update rust crate serde to v1.0.204 (#16) - ([1321386](https://codeberg.org/ThetaDev/artifactview/commit/13213861ba0ea30504caa50da2a99af567876e5c))
|
||||
- *(deps)* Update rust crate quick-xml to 0.36.0 (#19) - ([5f94794](https://codeberg.org/ThetaDev/artifactview/commit/5f94794d24f300762da2ab162f4336508b516eda))
|
||||
- *(deps)* Update rust crate async-compression to v0.4.12 (#22) - ([88c635c](https://codeberg.org/ThetaDev/artifactview/commit/88c635cbad535eb902ea54a314e8511965a792b4))
|
||||
- *(deps)* Update rust crate quick-xml to v0.36.1 (#23) - ([c5c9f85](https://codeberg.org/ThetaDev/artifactview/commit/c5c9f85e4baec58dcc2001ac3a7f005c7f501557))
|
||||
- *(deps)* Update rust crate quick_cache to v0.6.2 (#24) - ([a5d4973](https://codeberg.org/ThetaDev/artifactview/commit/a5d49733fd84ba37e8c258f191bf79f4affb86f9))
|
||||
- *(deps)* Update rust crate thiserror to v1.0.63 (#25) - ([b67b173](https://codeberg.org/ThetaDev/artifactview/commit/b67b1730b1e9c06b4ff99774c37c71391f48f93a))
|
||||
- *(deps)* Update rust crate comrak to 0.26.0 (#27) - ([642930d](https://codeberg.org/ThetaDev/artifactview/commit/642930d397efa106267bd2aff8c413ab4173a5c6))
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(deps)* Update rust crate axum-test to v15.3.0 (#17) - ([a88f1ba](https://codeberg.org/ThetaDev/artifactview/commit/a88f1ba91c054fdb267f0edef10aacf14a909694))
|
||||
- *(deps)* Lock file maintenance (#18) - ([f41a922](https://codeberg.org/ThetaDev/artifactview/commit/f41a92243c91086d5d774410b1452303fad64ccf))
|
||||
- *(deps)* Update rust crate env_logger to v0.11.5 (#20) - ([c9db056](https://codeberg.org/ThetaDev/artifactview/commit/c9db0567916e500017034d6a99eb48a25a1671e0))
|
||||
- *(deps)* Update rust crate scraper to v0.19.1 (#21) - ([1a5c056](https://codeberg.org/ThetaDev/artifactview/commit/1a5c056204b488e36ef95145b05a674c661a2154))
|
||||
- *(deps)* Update rust crate tokio to v1.39.1 (#26) - ([057a365](https://codeberg.org/ThetaDev/artifactview/commit/057a365a0ecafe00fa84e53c736272f5db26f0f1))
|
||||
- *(deps)* Update rust crate tokio to v1.39.2 (#28) - ([3ae7f88](https://codeberg.org/ThetaDev/artifactview/commit/3ae7f8813e19818098a6b67d0c6770db8a79defe))
|
||||
- *(deps)* Update rust crate zip to v2 (#9) - ([a7160fa](https://codeberg.org/ThetaDev/artifactview/commit/a7160fadde6c95c6c03c90c5d2301738c8e559c2))
|
||||
|
||||
|
||||
## [v0.4.5](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.4..v0.4.5) - 2024-07-01
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Update PR comment format - ([197eeea](https://codeberg.org/ThetaDev/artifactview/commit/197eeea75baa8ba44d27ec46c5f552028052869b))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(deps)* Update rust crate serde_json to v1.0.118 (#5) - ([fc3b5a1](https://codeberg.org/ThetaDev/artifactview/commit/fc3b5a1530985012ff8364a8fa676626e7544eaf))
|
||||
- *(deps)* Update rust crate quick-xml to 0.34.0 (#8) - ([595a9d0](https://codeberg.org/ThetaDev/artifactview/commit/595a9d0f4115faf5056653406b6d05bf671dc2b3))
|
||||
- *(deps)* Update rust crate mime_guess to v2.0.5 (#10) - ([94191f8](https://codeberg.org/ThetaDev/artifactview/commit/94191f878d774bef165cca850fcdf00fde16d662))
|
||||
- *(deps)* Update rust crate quick-xml to 0.35.0 (#11) - ([7ebe881](https://codeberg.org/ThetaDev/artifactview/commit/7ebe8815462f3e704a79af038b9b1850ed4677ed))
|
||||
- *(deps)* Update rust crate serde_json to v1.0.119 (#12) - ([2525022](https://codeberg.org/ThetaDev/artifactview/commit/2525022df76b3c16951983c14a55fa9617114a8e))
|
||||
- Swap crc and size column (#3) - ([7d2c686](https://codeberg.org/ThetaDev/artifactview/commit/7d2c68630ec6e75061c050a4c8b035edb472d150))
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- Make example CI step compatible with GitHub+Forgejo - ([39f0019](https://codeberg.org/ThetaDev/artifactview/commit/39f0019455cc23f1b8c39b77d2aaa5af278731a9))
|
||||
- Update README - ([40ae3a7](https://codeberg.org/ThetaDev/artifactview/commit/40ae3a7f557c63a0bb2abcd595218c8ec1095fe7))
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(deps)* Update rust crate proptest to v1.5.0 (#6) - ([797fc0c](https://codeberg.org/ThetaDev/artifactview/commit/797fc0c04c2a51811a24cfc431496e6e5dbf0bea))
|
||||
- *(deps)* Update rust crate rstest to 0.21.0 (#7) - ([cec3aa3](https://codeberg.org/ThetaDev/artifactview/commit/cec3aa3fc02e6a871d9c221f61c3f2d8828f9f63))
|
||||
- *(deps)* Lock file maintenance (#13) - ([9767167](https://codeberg.org/ThetaDev/artifactview/commit/9767167661e22775614cea7b888a19ee16c17d65))
|
||||
|
||||
|
||||
## [v0.4.4](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.3..v0.4.4) - 2024-06-22
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Use forge aliases for PR comment links - ([3690b02](https://codeberg.org/ThetaDev/artifactview/commit/3690b0244cf47d0d73511f5f69f5d12abe0f1837))
|
||||
|
||||
|
||||
## [v0.4.3](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.2..v0.4.3) - 2024-06-22
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- 404 error on GitHub comment creation - ([d8c3ab4](https://codeberg.org/ThetaDev/artifactview/commit/d8c3ab4f36727f118b31683db87d287d9945ee14))
|
||||
|
||||
|
||||
## [v0.4.2](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.1..v0.4.2) - 2024-06-22
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- PR comment emoji prefix detection - ([1e36edf](https://codeberg.org/ThetaDev/artifactview/commit/1e36edf49978e8ba24a85d4663ff3ebaf9642a29))
|
||||
|
||||
|
||||
## [v0.4.1](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.0..v0.4.1) - 2024-06-22
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Improved PR comment format, add `artifact_paths` parameter - ([6ae7520](https://codeberg.org/ThetaDev/artifactview/commit/6ae7520111469b04764115ccb5eeb3b756a4572e))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Return correct status code on API errors - ([f94cdcb](https://codeberg.org/ThetaDev/artifactview/commit/f94cdcbd1fec5474145e845a8673470a174bf6f5))
|
||||
|
||||
|
||||
## [v0.4.0](https://codeberg.org/ThetaDev/artifactview/compare/v0.3.1..v0.4.0) - 2024-06-22
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Create PR comments - ([d0cdbf5](https://codeberg.org/ThetaDev/artifactview/commit/d0cdbf55a3a278ce21cab11885170e9f4a6d4094))
|
||||
- Add url parameter to /artifacts API - ([23b8101](https://codeberg.org/ThetaDev/artifactview/commit/23b81014266728e3db1cb200a5cf46a212baed72))
|
||||
|
||||
|
||||
## [v0.3.1](https://codeberg.org/ThetaDev/artifactview/compare/v0.3.0..v0.3.1) - 2024-06-19
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Add instance domain to userscript description - ([311d3ae](https://codeberg.org/ThetaDev/artifactview/commit/311d3aedb968d1103569300d9c7f117edd96ae45))
|
||||
- Do not show fallback pages for favicon - ([f1f0af6](https://codeberg.org/ThetaDev/artifactview/commit/f1f0af62647074e2cce6eb52fb188e014b248ace))
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Update quick-xml to v0.32.0 - ([604d650](https://codeberg.org/ThetaDev/artifactview/commit/604d650d49180b80784948e895508aa7e0048211))
|
||||
- Update dependencies - ([68ad001](https://codeberg.org/ThetaDev/artifactview/commit/68ad00170fc8b0a881bbdeb36e50374e335b3f7f))
|
||||
|
||||
|
||||
## [v0.3.0](https://codeberg.org/ThetaDev/artifactview/compare/v0.2.0..v0.3.0) - 2024-06-18
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add userscript - ([299f54f](https://codeberg.org/ThetaDev/artifactview/commit/299f54fd58b567884f76e1c36c7c1648d43fbc75))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Make icon visible on light background - ([7021980](https://codeberg.org/ThetaDev/artifactview/commit/70219805d0029bb5b1e0bd08f77f444949d5b9f6))
|
||||
- Redirect user to directory path when requesting index page - ([a8e173c](https://codeberg.org/ThetaDev/artifactview/commit/a8e173c8a921ab29f4e70b7abd5167fb87c6f609))
|
||||
|
||||
|
||||
## [v0.2.0](https://codeberg.org/ThetaDev/artifactview/compare/v0.1.0..v0.2.0) - 2024-06-14
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add port config option - ([eca80aa](https://codeberg.org/ThetaDev/artifactview/commit/eca80aaa8e7ff9d3b36991f9d80d8846441a5536))
|
||||
- Add viewer - ([47f3ea1](https://codeberg.org/ThetaDev/artifactview/commit/47f3ea126784c3add59ef5feea94f11f8d4413b2))
|
||||
- Add viewer selection - ([4ebeb4b](https://codeberg.org/ThetaDev/artifactview/commit/4ebeb4b873d3b935a2eaa4008c0fc30dfd87c0f7))
|
||||
- Add markdown viewer - ([608a9f6](https://codeberg.org/ThetaDev/artifactview/commit/608a9f68f4e756bf65c0084fbf654c8e40d4762c))
|
||||
- Add junit-parser crate - ([06c09b2](https://codeberg.org/ThetaDev/artifactview/commit/06c09b2831d4ba9fe4095776b3c591cd64c095a9))
|
||||
- Add junit-parser - ([ec65b6d](https://codeberg.org/ThetaDev/artifactview/commit/ec65b6d03f13d88dbf874b72b086eec369d94475))
|
||||
- Add JUnit viewer template - ([806a2dd](https://codeberg.org/ThetaDev/artifactview/commit/806a2dda9a5e7ce33e0496b31934a2cf11eeb2ae))
|
||||
- Add compressed stylesheets - ([134bdaa](https://codeberg.org/ThetaDev/artifactview/commit/134bdaa34bd750a64a8b7b33d5d4383de6f51188))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Make url input field required - ([5a54f0a](https://codeberg.org/ThetaDev/artifactview/commit/5a54f0a7a6828b29ea67269c1474e9ebbafdca2a))
|
||||
- Fix markdown styling - ([93758e3](https://codeberg.org/ThetaDev/artifactview/commit/93758e3ab2f86e2edf8d7f04b8786e74e2ec67d6))
|
||||
- Handling zip files containing dir entries - ([8405af8](https://codeberg.org/ThetaDev/artifactview/commit/8405af8fb339418dc0b17768e8ef3bc1f22d46f5))
|
||||
- Increment stylesheet path - ([d9f9d6e](https://codeberg.org/ThetaDev/artifactview/commit/d9f9d6edcaef4086ce51596305909a1d63e132f7))
|
||||
- Improve path header - ([194499b](https://codeberg.org/ThetaDev/artifactview/commit/194499b2768325e27dc77e0f3a71c410667a8851))
|
||||
- Clippy error, async_zip tests - ([5f22997](https://codeberg.org/ThetaDev/artifactview/commit/5f229977d84f50911a42fd66f07f567727098968))
|
||||
- Dont cache empty artifact lists - ([09a5e56](https://codeberg.org/ThetaDev/artifactview/commit/09a5e5639e0ed24f437c0ed81e464530a6b7ee99))
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- [**breaking**] Don't use URL queries for artifact selection - ([72c0e3a](https://codeberg.org/ThetaDev/artifactview/commit/72c0e3af69e101c3690696a065b573624e19626d))
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
- Add integration tests - ([4aaff46](https://codeberg.org/ThetaDev/artifactview/commit/4aaff462bb602180832cae3fe902161d2f8043a9))
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Update repo URL to Codeberg - ([6a0171c](https://codeberg.org/ThetaDev/artifactview/commit/6a0171c62a7cf1023c8efe203e202a6bccdc04e5))
|
||||
|
||||
|
||||
## [v0.1.0](https://codeberg.org/ThetaDev/artifactview/commits/tag/v0.1.0) - 2024-05-31
|
||||
|
||||
Initial release
|
||||
|
|
2602
Cargo.lock
generated
48
Cargo.toml
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "artifactview"
|
||||
version = "0.1.0"
|
||||
version = "0.4.9"
|
||||
edition = "2021"
|
||||
authors = ["ThetaDev <thetadev@magenta.de>"]
|
||||
license = "MIT"
|
||||
|
@ -21,49 +21,73 @@ async_zip = { path = "crates/async_zip", features = [
|
|||
"tokio-fs",
|
||||
"deflate",
|
||||
] }
|
||||
axum = { version = "0.7.5", features = ["http2"] }
|
||||
axum-extra = { version = "0.9.3", features = ["typed-header"] }
|
||||
axum = { version = "0.8.0", default-features = false, features = [
|
||||
"http1",
|
||||
"http2",
|
||||
"json",
|
||||
"query",
|
||||
"tokio",
|
||||
"tracing",
|
||||
] }
|
||||
axum-extra = { version = "0.10.0", features = ["typed-header"] }
|
||||
comrak = { version = "0.40.0", default-features = false }
|
||||
dotenvy = "0.15.7"
|
||||
envy = { path = "crates/envy" }
|
||||
flate2 = "1.0.30"
|
||||
futures-lite = "2.3.0"
|
||||
governor = "0.6.3"
|
||||
governor = "0.10.0"
|
||||
headers = "0.4.0"
|
||||
hex = "0.4.3"
|
||||
http = "1.1.0"
|
||||
humansize = "2.1.3"
|
||||
junit-parser = { path = "crates/junit-parser" }
|
||||
mime = "0.3.17"
|
||||
mime_guess = "2.0.4"
|
||||
once_cell = "1.19.0"
|
||||
path_macro = "1.0.0"
|
||||
percent-encoding = "2.3.1"
|
||||
pin-project = "1.1.5"
|
||||
quick_cache = "0.5.1"
|
||||
rand = "0.8.5"
|
||||
quick_cache = "0.6.0"
|
||||
rand = "0.9.0"
|
||||
regex = "1.10.4"
|
||||
reqwest = { version = "0.12.4", default-features = false, features = [
|
||||
"json",
|
||||
"stream",
|
||||
] }
|
||||
secrecy = { version = "0.10.0", features = ["serde"] }
|
||||
serde = { version = "1.0.203", features = ["derive"] }
|
||||
serde-env = "0.1.1"
|
||||
serde-env = "0.2.0"
|
||||
serde-hex = "0.1.0"
|
||||
serde_json = "1.0.117"
|
||||
thiserror = "1.0.61"
|
||||
serde_urlencoded = "0.7.1"
|
||||
syntect = { version = "5.2.0", default-features = false, features = [
|
||||
"parsing",
|
||||
"default-syntaxes",
|
||||
"default-themes",
|
||||
"html",
|
||||
"regex-onig",
|
||||
] }
|
||||
thiserror = "2.0.0"
|
||||
time = { version = "0.3.36", features = ["serde-human-readable", "macros"] }
|
||||
tokio = { version = "1.37.0", features = ["macros", "fs", "rt-multi-thread"] }
|
||||
tokio-util = { version = "0.7.11", features = ["io"] }
|
||||
tower-http = { version = "0.5.2", features = ["trace", "set-header"] }
|
||||
tower-http = { version = "0.6.0", features = ["trace", "set-header"] }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = "0.3.18"
|
||||
url = "2.5.0"
|
||||
yarte = "0.15.7"
|
||||
yarte = { version = "0.15.7", features = ["json"] }
|
||||
|
||||
[build-dependencies]
|
||||
yarte_helpers = "0.15.8"
|
||||
|
||||
[dev-dependencies]
|
||||
axum-test = "17.0.0"
|
||||
flate2 = "1.0.30"
|
||||
httpdate = "1.0.3"
|
||||
insta = { version = "1.39.0", features = ["json"] }
|
||||
proptest = "1.4.0"
|
||||
rstest = { version = "0.20.0", default-features = false }
|
||||
rstest = { version = "0.25.0", default-features = false }
|
||||
scraper = "0.23.0"
|
||||
temp_testdir = "0.2.3"
|
||||
|
||||
[workspace]
|
||||
members = [".", "crates/*"]
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
FROM debian:bookworm-slim
|
||||
|
||||
ARG TARGETARCH_ALT
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apt-get update && apt-get install -y libssl3 ca-certificates dumb-init && apt-get clean
|
||||
|
||||
COPY target/${TARGETARCH_ALT}-unknown-linux-gnu/release/artifactview /usr/bin/artifactview
|
||||
COPY target/${TARGETARCH}/release/artifactview /usr/bin/artifactview
|
||||
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["dumb-init", "artifactview"]
|
||||
|
|
7
Justfile
|
@ -1,5 +1,8 @@
|
|||
test:
|
||||
cargo test
|
||||
cargo nextest run --no-fail-fast
|
||||
|
||||
compress-res:
|
||||
cd resources && zopfli *.css
|
||||
|
||||
release:
|
||||
#!/usr/bin/env bash
|
||||
|
@ -22,7 +25,7 @@ release:
|
|||
eval "git-cliff $CLIFF_ARGS --output '$CHANGELOG'"
|
||||
fi
|
||||
|
||||
git add "$CHANGELOG"
|
||||
git add .
|
||||
git commit -m "chore(release): release $CRATE v$VERSION"
|
||||
|
||||
awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' "$CHANGELOG" | git tag -as -F - --cleanup whitespace "$TAG"
|
||||
|
|
323
README.md
|
@ -1,40 +1,325 @@
|
|||
# Artifactview
|
||||
|
||||
View CI build artifacts from Forgejo/Github using your web browser.
|
||||
View CI build artifacts from Forgejo/GitHub using your web browser!
|
||||
|
||||
Forgejo and GitHub's CI systems allow you to upload files and directories as
|
||||
[artifacts](https://github.com/actions/upload-artifact). These can be downloaded as zip
|
||||
files. However there is no simple way to view individual files of an artifact.
|
||||
|
||||
Artifactview is a small web application that can fetch these CI artifacts and serve
|
||||
their contents. If the artifact contains a website, it is displayed normally, if it consists
|
||||
of other files, a file listing is shown.
|
||||
That's why I developed Artifactview. It is a small web application that fetches these CI
|
||||
artifacts and serves their contents.
|
||||
|
||||
There is also full support for single page applications, placing a file named `200.html` in the
|
||||
root directory it will be returned in case no file exists for the requested path.
|
||||
It is a valuable tool in open source software development: you can quickly look at test
|
||||
reports or coverage data or showcase your single page web applications to your
|
||||
teammates.
|
||||
|
||||
Alternatively, if a file named `404.html` exists in the root directory, it will be returned with
|
||||
status code 404 if no file was found.
|
||||
## Features
|
||||
|
||||
- 📦 Quickly view CI artifacts in your browser without messing with zip files
|
||||
- 📂 File listing for directories without index page
|
||||
- 🏠 Every artifact has a unique subdomain to support pages with absolute paths
|
||||
- 🌎 Full SPA support with `200.html` and `404.html` fallback pages
|
||||
- 👁️ Viewer for Markdown, syntax-highlighted code and JUnit test reports
|
||||
- 🐵 Greasemonkey userscript to automatically add a "View artifact" button to
|
||||
GitHub/Gitea/Forgejo
|
||||
- 🦀 Fast and efficient, only extracts files from zip archive if the client does not support gzip
|
||||
- 🔗 Automatically creates pull request comments with links to all build artifacts
|
||||
|
||||
## How to use
|
||||
|
||||
Artifactview accepts URLs in the given format: `<HOST>--<USER>--<REPO>--<RUN>-<ARTIFACT>.example.com`
|
||||
Open a Github/Gitea/Forgejo actions run with artifacts and paste its URL into the input
|
||||
box on the main page. You can also pass the run URL with the `?url=` parameter.
|
||||
|
||||
Artifactview will show you a selection page where you will be able to choose the
|
||||
artifact you want to browse.
|
||||
|
||||
If there is no `index.html` or fallback page present, a file listing will be shown so
|
||||
you can browse the contents of the artifact.
|
||||
|
||||

|
||||
|
||||
If you want to use Artifactview to showcase a static website, you can make use of
|
||||
fallback pages. If a file named `200.html` is placed in the root directory, it will be
|
||||
served in case no file exists for the requested path. This allows serving single-page
|
||||
applications with custom routing. A custom 404 error page is defined using a file named
|
||||
`404.html` in the root directory.
|
||||
|
||||
The behavior is the same as with other web hosts like surge.sh, so a lot of website
|
||||
build tools already follow that convention.
|
||||
|
||||
Artifactview includes different viewers to better display files of certain types that
|
||||
browsers cannot handle by default. There is a renderer for markdown files as well as a
|
||||
syntax highlighter for source code files. The viewers are only shown if the files are
|
||||
accessed with the `?viewer=` URL parameter which is automatically set when opening a
|
||||
file from a directory listing. You can always download the raw version of the file via
|
||||
the link in the top right corner.
|
||||
|
||||

|
||||
|
||||
Artifactview even includes an interactive viewer for JUnit test reports (XML files with
|
||||
`junit` in their filename). The application has been designed to be easily extendable,
|
||||
so if you have suggestions on other viewers that should be added, feel free to create an
|
||||
issue or a PR.
|
||||
|
||||

|
||||
|
||||
Accessing Artifactview by copying the CI run URL into its homepage may be a little bit
|
||||
tedious. That's why there are some convenient alternatives available.
|
||||
|
||||
You can install the Greasemonkey userscript from the link at the bottom of the homepage.
|
||||
The script adds a "View artifact" link with an eye icon next to every CI artifact on
|
||||
both GitHub and Forgejo.
|
||||
|
||||
If you want to give every collaborator to your project easy access to previews, you can
|
||||
automatically create a comment with links to the artifacts under every pull request.
|
||||
|
||||

|
||||
|
||||
To accomplish that, simply add this step to your CI workflow (after uploading the
|
||||
artifacts).
|
||||
|
||||
```yaml
|
||||
- name: 🔗 Artifactview PR comment
|
||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||
run: |
|
||||
if [[ "$GITEA_ACTIONS" == "true" ]]; then RUN_NUMBER="$GITHUB_RUN_NUMBER"; else RUN_NUMBER="$GITHUB_RUN_ID"; fi
|
||||
curl -SsL --fail-with-body -w "\n" -X POST https://av.thetadev.de/.well-known/api/prComment -H "Content-Type: application/json" \
|
||||
--data '{"url": "'"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$RUN_NUMBER"'", "pr": ${{ github.event.number }}}'
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
Artifactview does have a HTTP API to access data about the CI artifacts. To make the API
|
||||
available to every site without interfering with any paths from the artifacts, the
|
||||
endpoints are located within the reserved `/.well-known/api` directory.
|
||||
|
||||
### Get list of artifacts of a CI run
|
||||
|
||||
`GET /.well-known/api/artifacts?url=<RUN_URL>`
|
||||
|
||||
`GET <HOST>--<USER>--<REPO>--<RUN>-<ARTIFACT>.example.com/.well-known/api/artifacts`
|
||||
|
||||
**Response**
|
||||
|
||||
**Note:** the difference between `download_url` and `user_download_url` is that the
|
||||
first one is used by the API client and the second one is shown to the user.
|
||||
`user_download_url` is only set for GitHub artifacts. Forgejo does not have different
|
||||
download URLs since it does not require authentication to download artifacts.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Example",
|
||||
"size": 1523222,
|
||||
"expired": false,
|
||||
"download_url": "https://codeberg.org/thetadev/artifactview/actions/runs/28/artifacts/Example",
|
||||
"user_download_url": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Get metadata of the current artifact
|
||||
|
||||
`GET <HOST>--<USER>--<REPO>--<RUN>-<ARTIFACT>.example.com/.well-known/api/artifact`
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Example",
|
||||
"size": 1523222,
|
||||
"expired": false,
|
||||
"download_url": "https://codeberg.org/thetadev/artifactview/actions/runs/28/artifacts/Example",
|
||||
"user_download_url": null
|
||||
}
|
||||
```
|
||||
|
||||
### Get all files from the artifact
|
||||
|
||||
`GET <HOST>--<USER>--<REPO>--<RUN>-<ARTIFACT>.example.com/.well-known/api/files`
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
[
|
||||
{ "name": "example.rs", "size": 406, "crc32": "2013120c" },
|
||||
{ "name": "README.md", "size": 13060, "crc32": "61c692f0" }
|
||||
]
|
||||
```
|
||||
|
||||
### Create a pull request comment
|
||||
|
||||
`POST /.well-known/api/prComment`
|
||||
|
||||
Artifactview can create a comment under a pull request containing links to view the
|
||||
artifacts. This way everyone looking at a project can easily access the artifact
|
||||
previews.
|
||||
|
||||
To use this feature, you need to setup an access token with the permission to create
|
||||
comments for every code forge you want to use (more details in the section
|
||||
[Access tokens](#access-tokens)).
|
||||
|
||||
To prevent abuse and spamming, this endpoint is rate-limited and Artifactview will only
|
||||
create comments after it verified that the workflow matches the given pull request and
|
||||
the worflow is still running.
|
||||
|
||||
| JSON parameter | Description |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `url` (string) ❕ | CI workflow URL<br />Example: https://codeberg.org/ThetaDev/artifactview/actions/runs/31 |
|
||||
| `pr` (int) ❕ | Pull request number |
|
||||
| `recreate` (bool) | If set to true, the pull request comment will be deleted and recreated if it already exists. If set to false or omitted, the comment will be edited instead. |
|
||||
| `title` (string) | Comment title (default: "Latest build artifacts") |
|
||||
| `artifact_titles` (map) | Set custom titles for your artifacts.<br />Example: `{"Hello": "🏠 Hello World ;-)"}` |
|
||||
| `artifact_paths` (map) | Set custom paths for your artifacts if you want the links to point to a specific file (e.g. a test report).<br />Example: `{"Test": "/junit.xml?viewer=1"}` |
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{ "status": 200, "msg": "created comment #2183634497" }
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
You can run artifactview using the docker image provided under
|
||||
`thetadev256/artifactview:latest` or bare-metal using the provided binaries.
|
||||
|
||||
Artifactview is designed to run behind a reverse proxy since it does not support HTTPS
|
||||
by itself. If you are using a reverse proxy, you have to set the `REAL_IP_HEADER` option
|
||||
to the client IP address header name provided by the proxy (usually `x-forwarded-for`.
|
||||
Otherwise artifactview will assume it is being accessed by only 1 client (the proxy
|
||||
itself) and the rate limiter would count all users as one.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Here is an example setup with docker-compose, using Traefik as a reverse proxy:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
artifactview:
|
||||
image: thetadev256/artifactview:latest
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- proxy
|
||||
environment:
|
||||
ROOT_DOMAIN: av.thetadev.de
|
||||
REAL_IP_HEADER: x-forwarded-for
|
||||
GITHUB_TOKEN: github_pat_123456
|
||||
REPO_WHITELIST: github.com;codeberg.org;code.thetadev.de
|
||||
SITE_ALIASES: gh=>github.com;cb=>codeberg.org;th=>code.thetadev.de
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.artifactview.entrypoints=websecure"
|
||||
- "traefik.http.routers.artifactview.rule=HostRegexp(`^[a-z0-9-]*.?av.thetadev.de$`)"
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Artifactview is configured using environment variables.
|
||||
|
||||
Note that some variables contain lists and maps of values. Lists need to have their
|
||||
values separated with semicolons. Maps use an arrow `=>` between key and value, with
|
||||
pairs separated by semicolons.
|
||||
|
||||
Example list: `foo;bar`, example map: `foo=>f1;bar=>b1`
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | 3000 | HTTP port |
|
||||
| `CACHE_DIR` | /tmp/artifactview | Temporary directory where to store the artifacts |
|
||||
| `ROOT_DOMAIN` | localhost:3000 | Public hostname+port number under which artifactview is accessible. If this is configured incorrectly, artifactview will show the error message "host does not end with configured ROOT_DOMAIN" |
|
||||
| `RUST_LOG` | info | Logging level |
|
||||
| `NO_HTTPS` | false | Set to True if the website is served without HTTPS (used if testing artifactview without an ) |
|
||||
| `MAX_ARTIFACT_SIZE` | 100000000 (100 MB) | Maximum size of the artifact zip file to be downloaded |
|
||||
| `MAX_FILE_SIZE` | 100000000 (100 MB) | Maximum contained file size to be served |
|
||||
| `MAX_FILE_COUNT` | 10000 | Maximum amount of files within a zip file |
|
||||
| `MAX_AGE_H` | 12 | Maximum age in hours after which cached artifacts are deleted |
|
||||
| `ZIP_TIMEOUT_MS` | 1000 | Maximum time in milliseconds for reading the index of a zip file. If this takes too long, the zip file is most likely excessively large or malicious (zip bomb) |
|
||||
| `GITHUB_TOKEN` | - | GitHub API token for downloading artifacts and creating PR comments. Using a fine-grained token with public read permissions is recommended |
|
||||
| `FORGEJO_TOKENS` | - | Forgejo API tokens for creating PR comments<br />Example: `codeberg.org=>fc010f65348468d05e570806275528c936ce93a4` |
|
||||
| `MEM_CACHE_SIZE` | 50 | Artifactview keeps artifact metadata as well as the zip file indexes in memory to improve performance. The amount of cached items is adjustable. |
|
||||
| `REAL_IP_HEADER` | - | Get the client IP address from a HTTP request header<br />If Artifactview is exposed to the network directly, this option has to be unset. If you are using a reverse proxy the proxy needs to be configured to send the actual client IP as a request header.<br />For most proxies this header is `x-forwarded-for`. |
|
||||
| `LIMIT_ARTIFACTS_PER_MIN` | 5 | Limit the amount of downloaded artifacts per IP address and minute to prevent excessive resource usage. |
|
||||
| `LIMIT_PR_COMMENTS_PER_MIN` | 5 | Limit the amount of pull request comment requests per IP address and minute to prevent spamming. |
|
||||
| `REPO_BLACKLIST` | - | List of sites/users/repos that can NOT be accessed. The blacklist takes precedence over the whitelist (repos included in both lists cannot be accessed)<br />Example: `github.com/evil-corp/world-destruction;codeberg.org/blackhat;example.org` |
|
||||
| `REPO_WHITELIST` | - | List of sites/users/repos that can ONLY be accessed. If the whitelist is empty, it will be ignored and any repository can be accessed. Uses the same syntax as `REPO_BLACKLIST`. |
|
||||
| `SITE_ALIASES` | - | Aliases for sites to make URLs shorter<br />Example: `gh => github.com;cb => codeberg.org` |
|
||||
| `SUGGESTED_SITES` | codeberg.org; github.com; gitea.com | List of suggested code forges (host only, without https://, separated by `;`). If repo_whitelist is empty, this value is used for the matched sites in the userscript. The first value is used in the placeholder URL on the home page. |
|
||||
| `VIEWER_MAX_SIZE` | 500000 | Maximum file size to be displayed using the viewer |
|
||||
|
||||
### Access tokens
|
||||
|
||||
GitHub does not allow downloading artifacts for public repositories for unauthenticated
|
||||
users. So you need to setup an access token to use Artifactview with GitHub.
|
||||
|
||||
If you are not using the `prComment` feature, you can use a fine-grained access token
|
||||
with the "Public repositories (read-only)" permission. If you want to create pull
|
||||
request comments, you have to use a classic token with the "public_repo" scope enabled
|
||||
(the fine-grained tokens did not work in my test).
|
||||
|
||||
Forgejo does not require access tokens to download artifacts on public repositories, so
|
||||
you only need to create a token if you want to use the `prComment`-API. In this case,
|
||||
the token needs the following permissions:
|
||||
|
||||
- Repository and Organization Access: Public only
|
||||
- issue: Read and write
|
||||
- user: Read (for determining own user ID)
|
||||
|
||||
Note that if you are using Artifactview to create pull request comments, it is
|
||||
recommended to create a second bot account instead of using your main account.
|
||||
|
||||
## Technical details
|
||||
|
||||
### URL format
|
||||
|
||||
Artifactview uses URLs in the given format for accessing the individual artifacts:
|
||||
`<HOST>--<USER>--<REPO>--<RUN>-<ARTIFACT>.hostname`
|
||||
|
||||
Example: `https://github-com--theta-dev--example-project--4-11.example.com`
|
||||
|
||||
## Security considerations
|
||||
The reason for using subdomains instead of URL paths is that many websites expect to be
|
||||
served from a separate subdomain and access resources using absolute paths. Using URLs
|
||||
like `example.com/github.com/theta-dev/example-project/4/11/path/to/file` would make the
|
||||
application easier to host, but it would not be possible to preview a React/Vue/Svelte
|
||||
web project.
|
||||
|
||||
It is recommended to use the whitelist feature to limit Artifactview to access only trusted
|
||||
servers, users and organizations.
|
||||
Since domains only allow letters, numbers and dashes but repository names allow dots and
|
||||
underscores, these escape sequences are used to access repositories with special
|
||||
characters in their names.
|
||||
|
||||
- `-0` -> `.`
|
||||
- `-1` -> `-`
|
||||
- `-2` -> `_`
|
||||
|
||||
Another issue with using subdomains is that they are limited to a maximum of 63
|
||||
characters. Most user and repository names are short enough for this not to become a
|
||||
problem, but it could still happen that a CI run becomes inaccessible. Since the run ID
|
||||
is incremented on each new CI run, it might even happen that Artifactview works fine at
|
||||
the beginning of a project, but the subdomains exceed the length limit in the future.
|
||||
|
||||
That's why I added aliases for forge URLs. You can for example alias github.com as gh,
|
||||
shaving 8 characters from the subdomain. This makes the subdomains short enogh that you
|
||||
will be unlikely to hit the limit even with longer user/project names.
|
||||
|
||||
### Security considerations
|
||||
|
||||
It is recommended to use the whitelist feature to allow artifactview to access only
|
||||
trusted servers, users and organizations.
|
||||
|
||||
Since many
|
||||
[well-known URIs](https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml)
|
||||
are used to configure security-relevant properties of a website or are used to attest
|
||||
ownership of a website (like `.well-known/acme-challenge` for issuing TLS certificates),
|
||||
Artifactview will serve no files from the `.well-known` folder.
|
||||
are used to configure security-relevant properties of a website or attest ownership of a
|
||||
website (like `.well-known/acme-challenge` for issuing TLS certificates), Artifactview
|
||||
will serve no files from the `.well-known` folder.
|
||||
|
||||
There is a configurable limit for both the maximum downloaded artifact size and the
|
||||
maximum size of individual files to be served (100MB by default).
|
||||
Additionally there is a configurable timeout for the zip file indexing operation.
|
||||
These measures should protect the server againt denial-of-service attacks like
|
||||
overfilling the server drive or uploading zip bombs.
|
||||
maximum size of individual files to be served (100 MB by default). Additionally there is
|
||||
a configurable timeout for the zip file indexing operation. These measures should
|
||||
protect the server against denial-of-service attacks like overfilling the server drive
|
||||
or uploading zip bombs.
|
||||
|
|
|
@ -37,7 +37,7 @@ rustdoc-args = ["--cfg", "docsrs"]
|
|||
crc32fast = "1"
|
||||
futures-lite = { version = "2.1.0", default-features = false, features = ["std"] }
|
||||
pin-project = "1"
|
||||
thiserror = "1"
|
||||
thiserror = "2"
|
||||
|
||||
async-compression = { version = "0.4.2", default-features = false, features = ["futures-io"], optional = true }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"], optional = true }
|
||||
|
@ -49,7 +49,7 @@ tokio-util = { version = "0.7", features = ["compat"], optional = true }
|
|||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["compat"] }
|
||||
env_logger = "0.11.2"
|
||||
zip = "0.6.3"
|
||||
zip = "2.2.2"
|
||||
|
||||
# shared across multiple examples
|
||||
# anyhow = "1"
|
||||
|
|
|
@ -15,6 +15,7 @@ pub struct CombinedCentralDirectoryRecord {
|
|||
pub num_entries_in_directory: u64,
|
||||
pub directory_size: u64,
|
||||
pub offset_of_start_of_directory: u64,
|
||||
#[allow(dead_code)]
|
||||
pub file_comment_length: u16,
|
||||
}
|
||||
|
||||
|
|
|
@ -51,7 +51,7 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
impl<'a, R, E> AsyncRead for ZipEntryReader<'a, R, E>
|
||||
impl<R, E> AsyncRead for ZipEntryReader<'_, R, E>
|
||||
where
|
||||
R: AsyncBufRead + Unpin,
|
||||
{
|
||||
|
@ -60,7 +60,7 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
impl<'a, R, E> ZipEntryReader<'a, R, E>
|
||||
impl<R, E> ZipEntryReader<'_, R, E>
|
||||
where
|
||||
R: AsyncBufRead + Unpin,
|
||||
{
|
||||
|
@ -118,7 +118,7 @@ enum OwnedEntry<'a> {
|
|||
Borrow(&'a ZipEntry),
|
||||
}
|
||||
|
||||
impl<'a> OwnedEntry<'a> {
|
||||
impl OwnedEntry<'_> {
|
||||
pub fn entry(&self) -> &'_ ZipEntry {
|
||||
match self {
|
||||
OwnedEntry::Owned(entry) => entry,
|
||||
|
|
|
@ -17,7 +17,7 @@ pub(crate) enum OwnedReader<'a, R> {
|
|||
Borrow(#[pin] &'a mut R),
|
||||
}
|
||||
|
||||
impl<'a, R> OwnedReader<'a, R>
|
||||
impl<R> OwnedReader<'_, R>
|
||||
where
|
||||
R: AsyncBufRead + Unpin,
|
||||
{
|
||||
|
@ -30,7 +30,7 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
impl<'a, R> AsyncBufRead for OwnedReader<'a, R>
|
||||
impl<R> AsyncBufRead for OwnedReader<'_, R>
|
||||
where
|
||||
R: AsyncBufRead + Unpin,
|
||||
{
|
||||
|
@ -49,7 +49,7 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
impl<'a, R> AsyncRead for OwnedReader<'a, R>
|
||||
impl<R> AsyncRead for OwnedReader<'_, R>
|
||||
where
|
||||
R: AsyncBufRead + Unpin,
|
||||
{
|
||||
|
|
|
@ -64,7 +64,7 @@ impl<'b, W: AsyncWrite + Unpin> CompressedAsyncWriter<'b, W> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<'b, W: AsyncWrite + Unpin> AsyncWrite for CompressedAsyncWriter<'b, W> {
|
||||
impl<W: AsyncWrite + Unpin> AsyncWrite for CompressedAsyncWriter<'_, W> {
|
||||
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context, buf: &[u8]) -> Poll<std::result::Result<usize, Error>> {
|
||||
match *self {
|
||||
CompressedAsyncWriter::Stored(ref mut inner) => Pin::new(inner).poll_write(cx, buf),
|
||||
|
|
|
@ -251,7 +251,7 @@ impl<'b, W: AsyncWrite + Unpin> EntryStreamWriter<'b, W> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<'a, W: AsyncWrite + Unpin> AsyncWrite for EntryStreamWriter<'a, W> {
|
||||
impl<W: AsyncWrite + Unpin> AsyncWrite for EntryStreamWriter<'_, W> {
|
||||
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context, buf: &[u8]) -> Poll<std::result::Result<usize, Error>> {
|
||||
let poll = Pin::new(&mut self.writer).poll_write(cx, buf);
|
||||
|
||||
|
|
|
@ -70,7 +70,7 @@ fn generate_zip64many_zip() -> std::path::PathBuf {
|
|||
|
||||
let zip_file = std::fs::File::create(&path).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(zip_file);
|
||||
let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let options = FileOptions::<()>::default().compression_method(zip::CompressionMethod::Stored);
|
||||
|
||||
for i in 0..2_u32.pow(16) + 1 {
|
||||
zip.start_file(format!("{i}.txt"), options).unwrap();
|
||||
|
|
|
@ -36,7 +36,7 @@ async fn test_write_zip64_file() {
|
|||
let cursor = std::io::Cursor::new(buffer);
|
||||
let mut zip = zip::read::ZipArchive::new(cursor).unwrap();
|
||||
let mut file1 = zip.by_name("file1").unwrap();
|
||||
assert_eq!(file1.extra_data(), &[] as &[u8]);
|
||||
assert_eq!(file1.extra_data(), Some(&[] as &[u8]));
|
||||
let mut buffer = Vec::new();
|
||||
file1.read_to_end(&mut buffer).unwrap();
|
||||
assert_eq!(buffer.as_slice(), &[0, 0, 0, 0]);
|
||||
|
@ -74,7 +74,7 @@ async fn test_write_large_zip64_file() {
|
|||
assert_eq!(zip64.compressed_size.unwrap(), BATCHED_FILE_SIZE as u64);
|
||||
assert_eq!(zip64.uncompressed_size.unwrap(), BATCHED_FILE_SIZE as u64);
|
||||
}
|
||||
e => panic!("Expected a Zip64 extended field, got {:?}", e),
|
||||
e => panic!("Expected a Zip64 extended field, got {e:?}"),
|
||||
}
|
||||
assert_eq!(cd_entry.header.uncompressed_size, NON_ZIP64_MAX_SIZE);
|
||||
assert_eq!(cd_entry.header.compressed_size, NON_ZIP64_MAX_SIZE);
|
||||
|
|
|
@ -15,15 +15,15 @@ impl StdError for Error {}
|
|||
impl fmt::Display for Error {
|
||||
fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Error::MissingValue(field) => write!(fmt, "missing value for {}", &field),
|
||||
Error::Custom(ref msg) => write!(fmt, "{}", msg),
|
||||
Error::MissingValue(field) => write!(fmt, "missing value for {field}"),
|
||||
Error::Custom(ref msg) => write!(fmt, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SerdeError for Error {
|
||||
fn custom<T: fmt::Display>(msg: T) -> Self {
|
||||
Error::Custom(format!("{}", msg))
|
||||
Error::Custom(format!("{msg}"))
|
||||
}
|
||||
|
||||
fn missing_field(field: &'static str) -> Error {
|
||||
|
|
|
@ -77,7 +77,7 @@ where
|
|||
|
||||
struct Val(String, String);
|
||||
|
||||
impl<'de> IntoDeserializer<'de, Error> for Val {
|
||||
impl IntoDeserializer<'_, Error> for Val {
|
||||
type Deserializer = Self;
|
||||
|
||||
fn into_deserializer(self) -> Self::Deserializer {
|
||||
|
@ -87,7 +87,7 @@ impl<'de> IntoDeserializer<'de, Error> for Val {
|
|||
|
||||
struct VarName(String);
|
||||
|
||||
impl<'de> IntoDeserializer<'de, Error> for VarName {
|
||||
impl IntoDeserializer<'_, Error> for VarName {
|
||||
type Deserializer = Self;
|
||||
|
||||
fn into_deserializer(self) -> Self::Deserializer {
|
||||
|
@ -248,7 +248,7 @@ struct Deserializer<'de, Iter: Iterator<Item = (String, String)>> {
|
|||
inner: MapDeserializer<'de, Vars<Iter>, Error>,
|
||||
}
|
||||
|
||||
impl<'de, Iter: Iterator<Item = (String, String)>> Deserializer<'de, Iter> {
|
||||
impl<Iter: Iterator<Item = (String, String)>> Deserializer<'_, Iter> {
|
||||
fn new(vars: Iter) -> Self {
|
||||
Deserializer {
|
||||
inner: MapDeserializer::new(Vars(vars)),
|
||||
|
@ -308,7 +308,7 @@ where
|
|||
/// These types are created with with the [prefixed](fn.prefixed.html) module function
|
||||
pub struct Prefixed<'a>(Cow<'a, str>);
|
||||
|
||||
impl<'a> Prefixed<'a> {
|
||||
impl Prefixed<'_> {
|
||||
/// Deserializes a type based on prefixed env variables
|
||||
pub fn from_env<T>(&self) -> Result<T>
|
||||
where
|
||||
|
@ -390,7 +390,7 @@ impl<'a> SplitEscaped<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for SplitEscaped<'a> {
|
||||
impl Iterator for SplitEscaped<'_> {
|
||||
type Item = String;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
|
@ -492,7 +492,7 @@ mod tests {
|
|||
newtype: CustomNewType(42)
|
||||
}
|
||||
),
|
||||
Err(e) => panic!("{:#?}", e),
|
||||
Err(e) => panic!("{e:#?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -564,7 +564,7 @@ mod tests {
|
|||
newtype: CustomNewType(42)
|
||||
}
|
||||
),
|
||||
Err(e) => panic!("{:#?}", e),
|
||||
Err(e) => panic!("{e:#?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
18
crates/junit-parser/Cargo.toml
Normal file
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "junit-parser"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Boris Faure <boris@fau.re>"]
|
||||
license = "BSD-2-Clause"
|
||||
repository = "https://github.com/borisfaure/junit-parser"
|
||||
|
||||
[dependencies]
|
||||
quick-xml = { version = "0.37.0", features = ["escape-html"] }
|
||||
thiserror = "2.0.0"
|
||||
time = { version = "0.3.36", features = ["parsing", "serde-well-known"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
insta = { version = "1.39.0", features = ["json"] }
|
||||
once_cell = "1.19.0"
|
||||
path_macro = "1.0.0"
|
40
crates/junit-parser/src/errors.rs
Normal file
|
@ -0,0 +1,40 @@
|
|||
#![warn(missing_docs)]
|
||||
use thiserror::Error;
|
||||
|
||||
/// Error enumerates all possible errors returned by this library.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
/// Error while parsing XML
|
||||
#[error("Error while parsing XML: {0}")]
|
||||
Xml(#[from] ::quick_xml::Error),
|
||||
/// Error while converting f64 attribute
|
||||
#[error("Error while converting f64 attribute: {0}")]
|
||||
ParseFloat(#[from] std::num::ParseFloatError),
|
||||
/// Error while converting u64 attribute
|
||||
#[error("Error while converting u64 attribute: {0}")]
|
||||
ParseInt(#[from] std::num::ParseIntError),
|
||||
/// Error while converting bytes to Utf8
|
||||
#[error("Error while converting bytes to Utf8: {0}")]
|
||||
ParseUt8(#[from] std::str::Utf8Error),
|
||||
#[error("Error while parsing timestamp: {0}")]
|
||||
ParseTimestamp(#[from] time::error::Parse),
|
||||
/// Error parsing the `property` element: missing `name`
|
||||
#[error("Missing `name` attribute in property")]
|
||||
MissingPropertyName,
|
||||
}
|
||||
|
||||
impl From<::quick_xml::events::attributes::AttrError> for Error {
|
||||
#[inline]
|
||||
/// Convert [`::quick_xml::events::attributes`] into [`Error::XMLError`]
|
||||
fn from(err: ::quick_xml::events::attributes::AttrError) -> Error {
|
||||
Error::Xml(err.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<::quick_xml::escape::EscapeError> for Error {
|
||||
#[inline]
|
||||
/// Convert [`::quick_xml::escape::EscapeError`] into [`Error::XMLError`]
|
||||
fn from(err: ::quick_xml::escape::EscapeError) -> Error {
|
||||
Error::Xml(err.into())
|
||||
}
|
||||
}
|
629
crates/junit-parser/src/lib.rs
Normal file
|
@ -0,0 +1,629 @@
|
|||
use std::borrow::Cow;
|
||||
use std::io::BufRead;
|
||||
|
||||
use quick_xml::errors::IllFormedError;
|
||||
use quick_xml::escape::unescape;
|
||||
use quick_xml::events::BytesStart as XMLBytesStart;
|
||||
use quick_xml::events::Event as XMLEvent;
|
||||
use quick_xml::name::QName;
|
||||
use quick_xml::Error as XMLError;
|
||||
use quick_xml::Reader as XMLReader;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::str;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
mod errors;
|
||||
|
||||
use errors::Error;
|
||||
|
||||
/// Struct representing a JUnit report, containing test suites
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct TestSuites {
|
||||
/// Name of the test suites, from the `name` attribute
|
||||
pub name: String,
|
||||
/// List of tests suites represented by [`TestSuite`]
|
||||
pub suites: Vec<TestSuite>,
|
||||
/// How long the test suites took to run, from the `time` attribute
|
||||
pub time: f64,
|
||||
/// Number of tests in the test suites, from the `tests` attribute
|
||||
pub tests: u64,
|
||||
/// Number of tests in error in the test suites, from the `errors` attribute
|
||||
pub errors: u64,
|
||||
/// Number of tests in failure in the test suites, from the `failures` attribute
|
||||
pub failures: u64,
|
||||
/// Number of tests skipped in the test suites, from the `skipped` attribute
|
||||
pub skipped: u64,
|
||||
/// Number of tests that passed after failed attempts
|
||||
pub flaky: u64,
|
||||
}
|
||||
|
||||
/// A test suite, containing test cases [`TestCase`]
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct TestSuite {
|
||||
/// Name of the test suite, from the `name` attribute
|
||||
pub name: String,
|
||||
/// Timestamp when the test suite was run, from the `timestamp` attribute
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub timestamp: Option<OffsetDateTime>,
|
||||
/// List of status of tests represented by [`TestCase`]
|
||||
pub cases: Vec<TestCase>,
|
||||
/// How long the test suite took to run, from the `time` attribute
|
||||
pub time: f64,
|
||||
/// Number of tests in the test suite, from the `tests` attribute
|
||||
pub tests: u64,
|
||||
/// Number of tests in error in the test suite, from the `errors` attribute
|
||||
pub errors: u64,
|
||||
/// Number of tests in failure in the test suite, from the `failures` attribute
|
||||
pub failures: u64,
|
||||
/// Number of tests skipped in the test suites, from the `skipped` attribute
|
||||
pub skipped: u64,
|
||||
/// Number of tests that passed after failed attempts
|
||||
pub flaky: u64,
|
||||
}
|
||||
|
||||
/// A test case
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct TestCase {
|
||||
/// Name of the test case, from the `name` attribute
|
||||
pub name: String,
|
||||
/// Original name, from the `name` attribute
|
||||
pub original_name: String,
|
||||
/// Class name, from the `classname` attribute
|
||||
pub classname: Option<String>,
|
||||
/// Timestamp when the test case was run
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub timestamp: Option<OffsetDateTime>,
|
||||
/// Run time in seconds
|
||||
pub time: f64,
|
||||
/// Status of the test case
|
||||
pub status: TestStatus,
|
||||
/// stdout output from the `system-out` element
|
||||
pub system_out: Option<String>,
|
||||
/// stderr output from the `system-err` element
|
||||
pub system_err: Option<String>,
|
||||
/// Previous test attempts, from `rerunFailure` and `flakyFailure` element
|
||||
pub retries: Vec<Retry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub enum TestStatus {
|
||||
#[default]
|
||||
Success,
|
||||
Error(Message),
|
||||
Failure(Message),
|
||||
Flaky,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
pub message: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Retry {
|
||||
/// Timestamp when the retry was run
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub timestamp: Option<OffsetDateTime>,
|
||||
/// Run time in seconds
|
||||
pub time: f64,
|
||||
/// Status of the retry
|
||||
pub status: TestStatus,
|
||||
/// stdout output from the `system-out` element
|
||||
pub system_out: Option<String>,
|
||||
/// stderr output from the `system-err` element
|
||||
pub system_err: Option<String>,
|
||||
}
|
||||
|
||||
impl TestSuites {
|
||||
/// Fill up `self` with attributes from the XML tag
|
||||
fn parse_attributes(&mut self, e: &XMLBytesStart) -> Result<(), Error> {
|
||||
for a in e.attributes() {
|
||||
let a = a?;
|
||||
match a.key {
|
||||
QName(b"time") => self.time = try_from_attribute_value_f64(a.value)?,
|
||||
QName(b"tests") => self.tests = try_from_attribute_value_u64(a.value)?,
|
||||
QName(b"errors") => self.errors = try_from_attribute_value_u64(a.value)?,
|
||||
QName(b"failures") => self.failures = try_from_attribute_value_u64(a.value)?,
|
||||
QName(b"skipped") => self.skipped = try_from_attribute_value_u64(a.value)?,
|
||||
QName(b"name") => self.name = try_from_attribute_value_string(a.value)?,
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// New [`TestSuites`] from empty XML tag
|
||||
fn new_empty(e: &XMLBytesStart) -> Result<Self, Error> {
|
||||
let mut ts = Self::default();
|
||||
ts.parse_attributes(e)?;
|
||||
Ok(ts)
|
||||
}
|
||||
|
||||
/// New [`TestSuites`] from XML tree
|
||||
fn from_reader<B: BufRead>(e: &XMLBytesStart, r: &mut XMLReader<B>) -> Result<Self, Error> {
|
||||
let mut ts = Self::default();
|
||||
ts.parse_attributes(e)?;
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
match r.read_event_into(&mut buf) {
|
||||
Ok(XMLEvent::End(ref e)) if e.name() == QName(b"testsuites") => break,
|
||||
Ok(XMLEvent::End(ref e)) if e.name() == QName(b"testrun") => break,
|
||||
Ok(XMLEvent::Start(ref e)) if e.name() == QName(b"testsuite") => {
|
||||
ts.suites.push(TestSuite::from_reader(e, r)?);
|
||||
}
|
||||
Ok(XMLEvent::Empty(ref e)) if e.name() == QName(b"testsuite") => {
|
||||
ts.suites.push(TestSuite::new_empty(e)?);
|
||||
}
|
||||
Ok(XMLEvent::Eof) => {
|
||||
return Err(XMLError::IllFormed(IllFormedError::MissingEndTag(
|
||||
"testsuites".to_string(),
|
||||
))
|
||||
.into());
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
ts.flaky = ts.suites.iter().map(|s| s.flaky).sum();
|
||||
|
||||
Ok(ts)
|
||||
}
|
||||
}
|
||||
|
||||
impl TestSuite {
|
||||
/// Fill up `self` with attributes from the XML tag
|
||||
fn parse_attributes(&mut self, e: &XMLBytesStart) -> Result<(), Error> {
|
||||
for a in e.attributes() {
|
||||
let a = a?;
|
||||
match a.key {
|
||||
QName(b"time") => self.time = try_from_attribute_value_f64(a.value)?,
|
||||
QName(b"tests") => self.tests = try_from_attribute_value_u64(a.value)?,
|
||||
QName(b"errors") => self.errors = try_from_attribute_value_u64(a.value)?,
|
||||
QName(b"failures") => self.failures = try_from_attribute_value_u64(a.value)?,
|
||||
QName(b"skipped") => self.skipped = try_from_attribute_value_u64(a.value)?,
|
||||
QName(b"name") => self.name = try_from_attribute_value_string(a.value)?,
|
||||
QName(b"timestamp") => {
|
||||
self.timestamp = Some(try_from_attribute_value_timestamp(a.value)?)
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// New [`TestSuite`] from empty XML tag
|
||||
fn new_empty(e: &XMLBytesStart) -> Result<Self, Error> {
|
||||
let mut ts = Self::default();
|
||||
ts.parse_attributes(e)?;
|
||||
Ok(ts)
|
||||
}
|
||||
|
||||
/// New [`TestSuite`] from XML tree
|
||||
fn from_reader<B: BufRead>(e: &XMLBytesStart, r: &mut XMLReader<B>) -> Result<Self, Error> {
|
||||
let mut ts = Self::default();
|
||||
ts.parse_attributes(e)?;
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
match r.read_event_into(&mut buf) {
|
||||
Ok(XMLEvent::End(ref e)) if e.name() == QName(b"testsuite") => break,
|
||||
Ok(XMLEvent::Start(ref e)) if e.name() == QName(b"testcase") => {
|
||||
ts.cases.push(TestCase::from_reader(e, r)?);
|
||||
}
|
||||
Ok(XMLEvent::Empty(ref e)) if e.name() == QName(b"testcase") => {
|
||||
ts.cases.push(TestCase::new_empty(e)?);
|
||||
}
|
||||
Ok(XMLEvent::Eof) => {
|
||||
return Err(XMLError::IllFormed(IllFormedError::MissingEndTag(
|
||||
"testsuite".to_string(),
|
||||
))
|
||||
.into());
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
ts.flaky = ts
|
||||
.cases
|
||||
.iter()
|
||||
.filter(|c| matches!(c.status, TestStatus::Flaky))
|
||||
.count() as u64;
|
||||
|
||||
Ok(ts)
|
||||
}
|
||||
}
|
||||
|
||||
impl TestCase {
|
||||
/// Fill up `self` with attributes from the XML tag
|
||||
fn parse_attributes(&mut self, e: &XMLBytesStart) -> Result<(), Error> {
|
||||
for a in e.attributes() {
|
||||
let a = a?;
|
||||
match a.key {
|
||||
QName(b"time") => self.time = try_from_attribute_value_f64(a.value)?,
|
||||
QName(b"timestamp") => {
|
||||
self.timestamp = Some(try_from_attribute_value_timestamp(a.value)?)
|
||||
}
|
||||
QName(b"name") => self.original_name = try_from_attribute_value_string(a.value)?,
|
||||
QName(b"classname") => {
|
||||
self.classname = Some(try_from_attribute_value_string(a.value)?)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(cn) = self.classname.as_ref() {
|
||||
self.name = format!("{}::{}", cn, self.original_name);
|
||||
} else {
|
||||
self.name.clone_from(&self.original_name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// New [`TestCase`] from empty XML tag
|
||||
fn new_empty(e: &XMLBytesStart) -> Result<Self, Error> {
|
||||
let mut tc = Self::default();
|
||||
tc.parse_attributes(e)?;
|
||||
Ok(tc)
|
||||
}
|
||||
|
||||
/// New [`TestCase`] from XML tree
|
||||
fn from_reader<B: BufRead>(e: &XMLBytesStart, r: &mut XMLReader<B>) -> Result<Self, Error> {
|
||||
let mut tc = Self::default();
|
||||
tc.parse_attributes(e)?;
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
match r.read_event_into(&mut buf)? {
|
||||
XMLEvent::End(ref e) if e.name() == QName(b"testcase") => break,
|
||||
XMLEvent::Start(ref e) if e.name() == QName(b"skipped") => {
|
||||
tc.status = TestStatus::Skipped;
|
||||
}
|
||||
XMLEvent::Empty(ref e) if e.name() == QName(b"skipped") => {
|
||||
tc.status = TestStatus::Skipped;
|
||||
}
|
||||
XMLEvent::Start(ref e) if e.name() == QName(b"failure") => {
|
||||
let msg = Message::from_reader(e, r)?;
|
||||
tc.status = TestStatus::Failure(msg);
|
||||
}
|
||||
XMLEvent::Empty(ref e) if e.name() == QName(b"failure") => {
|
||||
let msg = Message::new_empty(e)?;
|
||||
tc.status = TestStatus::Failure(msg);
|
||||
}
|
||||
XMLEvent::Start(ref e) if e.name() == QName(b"error") => {
|
||||
let msg = Message::from_reader(e, r)?;
|
||||
tc.status = TestStatus::Error(msg);
|
||||
}
|
||||
XMLEvent::Empty(ref e) if e.name() == QName(b"error") => {
|
||||
let msg = Message::new_empty(e)?;
|
||||
tc.status = TestStatus::Error(msg);
|
||||
}
|
||||
XMLEvent::Start(ref e) if e.name() == QName(b"system-out") => {
|
||||
tc.system_out = parse_system(e, r)?;
|
||||
}
|
||||
XMLEvent::Start(ref e) if e.name() == QName(b"system-err") => {
|
||||
tc.system_err = parse_system(e, r)?;
|
||||
}
|
||||
XMLEvent::Empty(ref e) if e.name() == QName(b"rerunFailure") => {
|
||||
tc.retries.push(Retry::new_empty(e)?);
|
||||
}
|
||||
XMLEvent::Start(ref e) if e.name() == QName(b"rerunFailure") => {
|
||||
tc.retries.push(Retry::from_reader(e, r)?);
|
||||
}
|
||||
XMLEvent::Empty(ref e) if e.name() == QName(b"flakyFailure") => {
|
||||
tc.status = TestStatus::Flaky;
|
||||
tc.retries.push(Retry::new_empty(e)?);
|
||||
}
|
||||
XMLEvent::Start(ref e) if e.name() == QName(b"flakyFailure") => {
|
||||
tc.status = TestStatus::Flaky;
|
||||
tc.retries.push(Retry::from_reader(e, r)?);
|
||||
}
|
||||
XMLEvent::Eof => {
|
||||
return Err(XMLError::IllFormed(IllFormedError::MissingEndTag(
|
||||
"testcase".to_string(),
|
||||
))
|
||||
.into());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Ok(tc)
|
||||
}
|
||||
|
||||
pub fn status_txt(&self) -> Cow<'static, str> {
|
||||
match self.status {
|
||||
TestStatus::Success => "Success".into(),
|
||||
TestStatus::Error(_) => "Error".into(),
|
||||
TestStatus::Failure(_) => {
|
||||
if self.retries.is_empty() {
|
||||
"Failure".into()
|
||||
} else {
|
||||
format!("Failure (after {} retries)", self.retries.len()).into()
|
||||
}
|
||||
}
|
||||
TestStatus::Flaky => format!(
|
||||
"Flaky (passed after {} failed attempt{})",
|
||||
self.retries.len(),
|
||||
if self.retries.len() == 1 { "s" } else { "" }
|
||||
)
|
||||
.into(),
|
||||
TestStatus::Skipped => "Skipped".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TestStatus {
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
TestStatus::Success => "success",
|
||||
TestStatus::Error(_) => "error",
|
||||
TestStatus::Failure(_) => "failure",
|
||||
TestStatus::Flaky => "flaky",
|
||||
TestStatus::Skipped => "skipped",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message(&self) -> Option<&Message> {
|
||||
match self {
|
||||
TestStatus::Error(msg) => Some(msg),
|
||||
TestStatus::Failure(msg) => Some(msg),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// Fill up `self` with attributes from the XML tag
|
||||
fn parse_attributes(&mut self, e: &XMLBytesStart) -> Result<(), Error> {
|
||||
for a in e.attributes() {
|
||||
let a = a?;
|
||||
if let QName(b"message") = a.key {
|
||||
self.message = try_from_attribute_value_string(a.value)?
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// New [`Message`] from empty XML tag
|
||||
fn new_empty(e: &XMLBytesStart) -> Result<Self, Error> {
|
||||
let mut tf = Self::default();
|
||||
tf.parse_attributes(e)?;
|
||||
Ok(tf)
|
||||
}
|
||||
|
||||
/// New [`Message`] from XML tree
|
||||
fn from_reader<B: BufRead>(e: &XMLBytesStart, r: &mut XMLReader<B>) -> Result<Self, Error> {
|
||||
let name = e.name();
|
||||
let mut msg = Self::default();
|
||||
msg.parse_attributes(e)?;
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
match r.read_event_into(&mut buf) {
|
||||
Ok(XMLEvent::End(ref e)) if e.name() == name => break,
|
||||
Ok(XMLEvent::Text(e)) => {
|
||||
msg.text += e.unescape()?.trim();
|
||||
}
|
||||
Ok(XMLEvent::Eof) => {
|
||||
return Err(XMLError::IllFormed(IllFormedError::MissingEndTag(
|
||||
String::from_utf8(name.0.to_vec()).unwrap_or_default(),
|
||||
))
|
||||
.into());
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Ok(msg)
|
||||
}
|
||||
}
|
||||
|
||||
impl Retry {
|
||||
/// Fill up `self` with attributes from the XML tag
|
||||
fn parse_attributes(&mut self, e: &XMLBytesStart) -> Result<(), Error> {
|
||||
for a in e.attributes() {
|
||||
let a = a?;
|
||||
match a.key {
|
||||
QName(b"time") => self.time = try_from_attribute_value_f64(a.value)?,
|
||||
QName(b"timestamp") => {
|
||||
self.timestamp = Some(try_from_attribute_value_timestamp(a.value)?)
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn new_empty(e: &XMLBytesStart) -> Result<Self, Error> {
|
||||
let mut rt = Self::default();
|
||||
rt.parse_attributes(e)?;
|
||||
Ok(rt)
|
||||
}
|
||||
|
||||
fn from_reader<B: BufRead>(e: &XMLBytesStart, r: &mut XMLReader<B>) -> Result<Self, Error> {
|
||||
let name = e.name();
|
||||
let mut rt = Self::default();
|
||||
rt.parse_attributes(e)?;
|
||||
|
||||
let mut msg = Message::new_empty(e)?;
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
match r.read_event_into(&mut buf) {
|
||||
Ok(XMLEvent::End(ref e)) if e.name() == name => break,
|
||||
Ok(XMLEvent::Text(e)) => {
|
||||
msg.text += e.unescape()?.trim();
|
||||
}
|
||||
Ok(XMLEvent::Start(ref e)) if e.name() == QName(b"system-out") => {
|
||||
rt.system_out = parse_system(e, r)?;
|
||||
}
|
||||
Ok(XMLEvent::Start(ref e)) if e.name() == QName(b"system-err") => {
|
||||
rt.system_err = parse_system(e, r)?;
|
||||
}
|
||||
Ok(XMLEvent::Eof) => {
|
||||
return Err(XMLError::IllFormed(IllFormedError::MissingEndTag(
|
||||
String::from_utf8(name.0.to_vec()).unwrap_or_default(),
|
||||
))
|
||||
.into());
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
rt.status = TestStatus::Failure(msg);
|
||||
Ok(rt)
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to decode attribute value as [`f64`]
|
||||
fn try_from_attribute_value_f64(value: Cow<[u8]>) -> Result<f64, Error> {
|
||||
match str::from_utf8(&value)? {
|
||||
"" => Ok(f64::default()),
|
||||
s => Ok(s.parse::<f64>()?),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to decode attribute value as [`u64`]
|
||||
fn try_from_attribute_value_u64(value: Cow<[u8]>) -> Result<u64, Error> {
|
||||
match str::from_utf8(&value)? {
|
||||
"" => Ok(u64::default()),
|
||||
s => Ok(s.parse::<u64>()?),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to decode and unescape attribute value as [`String`]
|
||||
fn try_from_attribute_value_string(value: Cow<[u8]>) -> Result<String, Error> {
|
||||
let s = str::from_utf8(&value)?;
|
||||
let u = unescape(s)?;
|
||||
Ok(u.to_string())
|
||||
}
|
||||
|
||||
/// Try to decode and unescape attribute value as [`String`]
|
||||
fn try_from_attribute_value_timestamp(value: Cow<[u8]>) -> Result<OffsetDateTime, Error> {
|
||||
let s = str::from_utf8(&value)?;
|
||||
let t = OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)?;
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// Parse a chunk of xml as system-out or system-err
|
||||
fn parse_system<B: BufRead>(
|
||||
orig: &XMLBytesStart,
|
||||
r: &mut XMLReader<B>,
|
||||
) -> Result<Option<String>, Error> {
|
||||
let mut buf = Vec::new();
|
||||
let mut res = None;
|
||||
loop {
|
||||
match r.read_event_into(&mut buf) {
|
||||
Ok(XMLEvent::End(ref e)) if e.name() == orig.name() => break,
|
||||
Ok(XMLEvent::Text(e)) => {
|
||||
res = Some(e.unescape()?.to_string());
|
||||
}
|
||||
Ok(XMLEvent::Eof) => {
|
||||
return Err(XMLError::IllFormed(IllFormedError::MissingEndTag(
|
||||
String::from_utf8(orig.name().0.to_vec()).unwrap_or_default(),
|
||||
))
|
||||
.into());
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Creates a [`TestSuites`](struct.TestSuites.html) structure from a JUnit XML data read from `reader`
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use std::io::Cursor;
|
||||
/// let xml = r#"
|
||||
/// <testsuite tests="3" failures="1">
|
||||
/// <testcase classname="foo1" name="ASuccessfulTest"/>
|
||||
/// <testcase classname="foo2" name="AnotherSuccessfulTest"/>
|
||||
/// <testcase classname="foo3" name="AFailingTest">
|
||||
/// <failure type="NotEnoughFoo"> details about failure </failure>
|
||||
/// </testcase>
|
||||
/// </testsuite>
|
||||
/// "#;
|
||||
/// let cursor = Cursor::new(xml);
|
||||
/// let r = junit_parser::from_reader(cursor);
|
||||
/// assert!(r.is_ok());
|
||||
/// ```
|
||||
pub fn from_reader<B: BufRead>(reader: B) -> Result<TestSuites, Error> {
|
||||
let mut r = XMLReader::from_reader(reader);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
match r.read_event_into(&mut buf) {
|
||||
Ok(XMLEvent::Empty(ref e)) if e.name() == QName(b"testsuites") => {
|
||||
return TestSuites::new_empty(e);
|
||||
}
|
||||
Ok(XMLEvent::Empty(ref e)) if e.name() == QName(b"testrun") => {
|
||||
return TestSuites::new_empty(e);
|
||||
}
|
||||
Ok(XMLEvent::Start(ref e)) if e.name() == QName(b"testsuites") => {
|
||||
return TestSuites::from_reader(e, &mut r);
|
||||
}
|
||||
Ok(XMLEvent::Start(ref e)) if e.name() == QName(b"testrun") => {
|
||||
return TestSuites::from_reader(e, &mut r);
|
||||
}
|
||||
Ok(XMLEvent::Empty(ref e)) if e.name() == QName(b"testsuite") => {
|
||||
let ts = TestSuite::new_empty(e)?;
|
||||
let mut suites = TestSuites::default();
|
||||
suites.suites.push(ts);
|
||||
return Ok(suites);
|
||||
}
|
||||
Ok(XMLEvent::Start(ref e)) if e.name() == QName(b"testsuite") => {
|
||||
let ts = TestSuite::from_reader(e, &mut r)?;
|
||||
let mut suites = TestSuites::default();
|
||||
suites.suites.push(ts);
|
||||
return Ok(suites);
|
||||
}
|
||||
Ok(XMLEvent::Eof) => {
|
||||
return Err(XMLError::IllFormed(IllFormedError::MissingEndTag(
|
||||
"testsuites".to_string(),
|
||||
))
|
||||
.into());
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a [`TestSuites`](struct.TestSuites.html) structure from a JUnit XML data read from a string
|
||||
pub fn from_str(s: &str) -> Result<TestSuites, Error> {
|
||||
from_reader(s.as_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{fs::File, io::BufReader, path::PathBuf};
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use path_macro::path;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub static TESTFILES: Lazy<PathBuf> =
|
||||
Lazy::new(|| path!(env!("CARGO_MANIFEST_DIR") / "testfiles"));
|
||||
|
||||
fn parse_test(n: &str) {
|
||||
let file = File::open(path!(*TESTFILES / format!("{n}.junit.xml"))).unwrap();
|
||||
let suites = from_reader(BufReader::new(file)).unwrap();
|
||||
insta::assert_json_snapshot!(format!("parse_{n}"), suites);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_simple() {
|
||||
parse_test("simple")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vite() {
|
||||
parse_test("vite")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_retry() {
|
||||
parse_test("retry")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,824 @@
|
|||
---
|
||||
source: crates/junit-parser/src/lib.rs
|
||||
expression: suites
|
||||
---
|
||||
{
|
||||
"name": "vitest tests",
|
||||
"suites": [
|
||||
{
|
||||
"name": "src/lib/server/query/util.test.ts",
|
||||
"timestamp": "2024-06-04T11:43:17.788Z",
|
||||
"cases": [
|
||||
{
|
||||
"name": "src/lib/server/query/util.test.ts::query builder",
|
||||
"original_name": "query builder",
|
||||
"classname": "src/lib/server/query/util.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.002,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/server/query/util.test.ts::parse search query",
|
||||
"original_name": "parse search query",
|
||||
"classname": "src/lib/server/query/util.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/server/query/util.test.ts::mapSortFields",
|
||||
"original_name": "mapSortFields",
|
||||
"classname": "src/lib/server/query/util.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
}
|
||||
],
|
||||
"time": 0.006,
|
||||
"tests": 3,
|
||||
"errors": 0,
|
||||
"failures": 0,
|
||||
"skipped": 0,
|
||||
"flaky": 0
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/model/validation.test.ts",
|
||||
"timestamp": "2024-06-04T11:43:17.789Z",
|
||||
"cases": [
|
||||
{
|
||||
"name": "src/lib/shared/model/validation.test.ts::date string",
|
||||
"original_name": "date string",
|
||||
"classname": "src/lib/shared/model/validation.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.002,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/model/validation.test.ts::filter data",
|
||||
"original_name": "filter data",
|
||||
"classname": "src/lib/shared/model/validation.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.002,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
}
|
||||
],
|
||||
"time": 0.004,
|
||||
"tests": 2,
|
||||
"errors": 0,
|
||||
"failures": 0,
|
||||
"skipped": 0,
|
||||
"flaky": 0
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": "2024-06-04T11:43:17.79Z",
|
||||
"cases": [
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > colorToHex",
|
||||
"original_name": "color conversion > colorToHex",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > hexToColor",
|
||||
"original_name": "color conversion > hexToColor",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > colorToHex",
|
||||
"original_name": "color conversion > colorToHex",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > hexToColor",
|
||||
"original_name": "color conversion > hexToColor",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > colorToHex",
|
||||
"original_name": "color conversion > colorToHex",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > hexToColor",
|
||||
"original_name": "color conversion > hexToColor",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > colorToHex",
|
||||
"original_name": "color conversion > colorToHex",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > hexToColor",
|
||||
"original_name": "color conversion > hexToColor",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > colorToHex",
|
||||
"original_name": "color conversion > colorToHex",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > hexToColor",
|
||||
"original_name": "color conversion > hexToColor",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > colorToHex",
|
||||
"original_name": "color conversion > colorToHex",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > hexToColor",
|
||||
"original_name": "color conversion > hexToColor",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > colorToHex",
|
||||
"original_name": "color conversion > colorToHex",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > hexToColor",
|
||||
"original_name": "color conversion > hexToColor",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > colorToHex",
|
||||
"original_name": "color conversion > colorToHex",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > hexToColor",
|
||||
"original_name": "color conversion > hexToColor",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > colorToHex",
|
||||
"original_name": "color conversion > colorToHex",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > hexToColor",
|
||||
"original_name": "color conversion > hexToColor",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > colorToHex",
|
||||
"original_name": "color conversion > colorToHex",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/colors.test.ts::color conversion > hexToColor",
|
||||
"original_name": "color conversion > hexToColor",
|
||||
"classname": "src/lib/shared/util/colors.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
}
|
||||
],
|
||||
"time": 0.005,
|
||||
"tests": 20,
|
||||
"errors": 0,
|
||||
"failures": 0,
|
||||
"skipped": 0,
|
||||
"flaky": 0
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": "2024-06-04T11:43:17.792Z",
|
||||
"cases": [
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::formatDate",
|
||||
"original_name": "formatDate",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.014,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::dateFromYMD",
|
||||
"original_name": "dateFromYMD",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::dateFromYMD",
|
||||
"original_name": "dateFromYMD",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::utcDateToYMD",
|
||||
"original_name": "utcDateToYMD",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::dateToYMD",
|
||||
"original_name": "dateToYMD",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::humanDate",
|
||||
"original_name": "humanDate",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::humanDate",
|
||||
"original_name": "humanDate",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::humanDate",
|
||||
"original_name": "humanDate",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::humanDate",
|
||||
"original_name": "humanDate",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::humanDate",
|
||||
"original_name": "humanDate",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::humanDate",
|
||||
"original_name": "humanDate",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::humanDate",
|
||||
"original_name": "humanDate",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::humanDate",
|
||||
"original_name": "humanDate",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.002,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::humanDate",
|
||||
"original_name": "humanDate",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::humanDate",
|
||||
"original_name": "humanDate",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::humanDate",
|
||||
"original_name": "humanDate",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::humanDate",
|
||||
"original_name": "humanDate",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::humanDate",
|
||||
"original_name": "humanDate",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::parse daterange ''",
|
||||
"original_name": "parse daterange ''",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::parse daterange '..'",
|
||||
"original_name": "parse daterange '..'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::parse daterange 'foo..bar'",
|
||||
"original_name": "parse daterange 'foo..bar'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::parse daterange '2024-04-15'",
|
||||
"original_name": "parse daterange '2024-04-15'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::parse daterange '2024-04-13..2024-04-20'",
|
||||
"original_name": "parse daterange '2024-04-13..2024-04-20'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::parse daterange '2024-04-13..'",
|
||||
"original_name": "parse daterange '2024-04-13..'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::parse daterange '..2024-04-20'",
|
||||
"original_name": "parse daterange '..2024-04-20'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::shiftDateRange '..2024-04-14'",
|
||||
"original_name": "shiftDateRange '..2024-04-14'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::shiftDateRange '..2024-04-14'",
|
||||
"original_name": "shiftDateRange '..2024-04-14'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::shiftDateRange '2024-04-08..'",
|
||||
"original_name": "shiftDateRange '2024-04-08..'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::shiftDateRange '2024-04-08..'",
|
||||
"original_name": "shiftDateRange '2024-04-08..'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::shiftDateRange '2024-04-08..2024-04-14'",
|
||||
"original_name": "shiftDateRange '2024-04-08..2024-04-14'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::shiftDateRange '2024-04-08..2024-04-14'",
|
||||
"original_name": "shiftDateRange '2024-04-08..2024-04-14'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::shiftDateRange '2024-04-13..2024-04-16'",
|
||||
"original_name": "shiftDateRange '2024-04-13..2024-04-16'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::shiftDateRange '2024-04-13..2024-04-16'",
|
||||
"original_name": "shiftDateRange '2024-04-13..2024-04-16'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::shiftDateRange '2024-04-13..2024-04-13'",
|
||||
"original_name": "shiftDateRange '2024-04-13..2024-04-13'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::shiftDateRange '2024-04-13..2024-04-13'",
|
||||
"original_name": "shiftDateRange '2024-04-13..2024-04-13'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::shiftDateRange '2024-04-08..2024-04-14'",
|
||||
"original_name": "shiftDateRange '2024-04-08..2024-04-14'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::shiftDateRange '2024-04-08..2024-04-14'",
|
||||
"original_name": "shiftDateRange '2024-04-08..2024-04-14'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::shiftDateRange '..2024-04-14'",
|
||||
"original_name": "shiftDateRange '..2024-04-14'",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.001,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/date.test.ts::dateFromHuman",
|
||||
"original_name": "dateFromHuman",
|
||||
"classname": "src/lib/shared/util/date.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
}
|
||||
],
|
||||
"time": 0.027,
|
||||
"tests": 39,
|
||||
"errors": 0,
|
||||
"failures": 0,
|
||||
"skipped": 0,
|
||||
"flaky": 0
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/diff.test.ts",
|
||||
"timestamp": "2024-06-04T11:43:17.795Z",
|
||||
"cases": [
|
||||
{
|
||||
"name": "src/lib/shared/util/diff.test.ts::versions diff",
|
||||
"original_name": "versions diff",
|
||||
"classname": "src/lib/shared/util/diff.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.003,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
}
|
||||
],
|
||||
"time": 0.003,
|
||||
"tests": 1,
|
||||
"errors": 0,
|
||||
"failures": 0,
|
||||
"skipped": 0,
|
||||
"flaky": 0
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/util.test.ts",
|
||||
"timestamp": "2024-06-04T11:43:17.795Z",
|
||||
"cases": [
|
||||
{
|
||||
"name": "src/lib/shared/util/util.test.ts::getQueryUrl",
|
||||
"original_name": "getQueryUrl",
|
||||
"classname": "src/lib/shared/util/util.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.005,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
},
|
||||
{
|
||||
"name": "src/lib/shared/util/util.test.ts::normalizeLineEndings",
|
||||
"original_name": "normalizeLineEndings",
|
||||
"classname": "src/lib/shared/util/util.test.ts",
|
||||
"timestamp": null,
|
||||
"time": 0.0,
|
||||
"status": "Success",
|
||||
"system_out": null,
|
||||
"system_err": null,
|
||||
"retries": []
|
||||
}
|
||||
],
|
||||
"time": 0.005,
|
||||
"tests": 2,
|
||||
"errors": 0,
|
||||
"failures": 0,
|
||||
"skipped": 0,
|
||||
"flaky": 0
|
||||
}
|
||||
],
|
||||
"time": 1.371,
|
||||
"tests": 67,
|
||||
"errors": 0,
|
||||
"failures": 0,
|
||||
"skipped": 0,
|
||||
"flaky": 0
|
||||
}
|
1859
crates/junit-parser/testfiles/retry.junit.xml
Normal file
1011
crates/junit-parser/testfiles/simple.junit.xml
Normal file
195
crates/junit-parser/testfiles/vite.junit.xml
Normal file
|
@ -0,0 +1,195 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites name="vitest tests" tests="67" failures="0" errors="0" time="1.371">
|
||||
<testsuite name="src/lib/server/query/util.test.ts" timestamp="2024-06-04T11:43:17.788Z"
|
||||
tests="3" failures="0" errors="0" skipped="0" time="0.006">
|
||||
<testcase classname="src/lib/server/query/util.test.ts" name="query builder" time="0.002">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/server/query/util.test.ts" name="parse search query" time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/server/query/util.test.ts" name="mapSortFields" time="0">
|
||||
</testcase>
|
||||
</testsuite>
|
||||
<testsuite name="src/lib/shared/model/validation.test.ts" timestamp="2024-06-04T11:43:17.789Z"
|
||||
tests="2" failures="0" errors="0" skipped="0" time="0.004">
|
||||
<testcase classname="src/lib/shared/model/validation.test.ts" name="date string" time="0.002">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/model/validation.test.ts" name="filter data" time="0.002">
|
||||
</testcase>
|
||||
</testsuite>
|
||||
<testsuite name="src/lib/shared/util/colors.test.ts" timestamp="2024-06-04T11:43:17.790Z"
|
||||
tests="20" failures="0" errors="0" skipped="0" time="0.005">
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > colorToHex"
|
||||
time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > hexToColor"
|
||||
time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > colorToHex"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > hexToColor"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > colorToHex"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > hexToColor"
|
||||
time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > colorToHex"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > hexToColor"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > colorToHex"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > hexToColor"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > colorToHex"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > hexToColor"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > colorToHex"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > hexToColor"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > colorToHex"
|
||||
time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > hexToColor"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > colorToHex"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > hexToColor"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > colorToHex"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/colors.test.ts" name="color conversion > hexToColor"
|
||||
time="0">
|
||||
</testcase>
|
||||
</testsuite>
|
||||
<testsuite name="src/lib/shared/util/date.test.ts" timestamp="2024-06-04T11:43:17.792Z"
|
||||
tests="39" failures="0" errors="0" skipped="0" time="0.027">
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="formatDate" time="0.014">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="dateFromYMD" time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="dateFromYMD" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="utcDateToYMD" time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="dateToYMD" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="humanDate" time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="humanDate" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="humanDate" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="humanDate" time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="humanDate" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="humanDate" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="humanDate" time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="humanDate" time="0.002">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="humanDate" time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="humanDate" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="humanDate" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="humanDate" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="humanDate" time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="parse daterange ''"
|
||||
time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="parse daterange '..'"
|
||||
time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="parse daterange 'foo..bar'" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="parse daterange '2024-04-15'" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="parse daterange '2024-04-13..2024-04-20'" time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="parse daterange '2024-04-13..'" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="parse daterange '..2024-04-20'" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="shiftDateRange '..2024-04-14'" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="shiftDateRange '..2024-04-14'" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="shiftDateRange '2024-04-08..'" time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="shiftDateRange '2024-04-08..'" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="shiftDateRange '2024-04-08..2024-04-14'" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="shiftDateRange '2024-04-08..2024-04-14'" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="shiftDateRange '2024-04-13..2024-04-16'" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="shiftDateRange '2024-04-13..2024-04-16'" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="shiftDateRange '2024-04-13..2024-04-13'" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="shiftDateRange '2024-04-13..2024-04-13'" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="shiftDateRange '2024-04-08..2024-04-14'" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="shiftDateRange '2024-04-08..2024-04-14'" time="0">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts"
|
||||
name="shiftDateRange '..2024-04-14'" time="0.001">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/date.test.ts" name="dateFromHuman" time="0">
|
||||
</testcase>
|
||||
</testsuite>
|
||||
<testsuite name="src/lib/shared/util/diff.test.ts" timestamp="2024-06-04T11:43:17.795Z"
|
||||
tests="1" failures="0" errors="0" skipped="0" time="0.003">
|
||||
<testcase classname="src/lib/shared/util/diff.test.ts" name="versions diff" time="0.003">
|
||||
</testcase>
|
||||
</testsuite>
|
||||
<testsuite name="src/lib/shared/util/util.test.ts" timestamp="2024-06-04T11:43:17.795Z"
|
||||
tests="2" failures="0" errors="0" skipped="0" time="0.005">
|
||||
<testcase classname="src/lib/shared/util/util.test.ts" name="getQueryUrl" time="0.005">
|
||||
</testcase>
|
||||
<testcase classname="src/lib/shared/util/util.test.ts" name="normalizeLineEndings" time="0">
|
||||
</testcase>
|
||||
</testsuite>
|
||||
</testsuites>
|
14
renovate.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:best-practices",
|
||||
":semanticCommitTypeAll(chore)",
|
||||
":preserveSemverRanges"
|
||||
],
|
||||
"automerge": true,
|
||||
"automergeStrategy": "squash",
|
||||
"osvVulnerabilityAlerts": true,
|
||||
"labels": ["dependency-upgrade"],
|
||||
"enabledManagers": ["cargo"],
|
||||
"prHourlyLimit": 5
|
||||
}
|
699
resources/content.css
Normal file
|
@ -0,0 +1,699 @@
|
|||
/* Additional stylesheet for artifactview content viewer */
|
||||
|
||||
.viewer > pre {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre,
|
||||
code {
|
||||
color: #cccccc;
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
|
||||
.prose {
|
||||
margin: 20px 20px 0 20px;
|
||||
max-width: 800px;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
.prose > :first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
.prose > :last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.prose h1,
|
||||
.prose h2,
|
||||
.prose h3,
|
||||
.prose h4,
|
||||
.prose h5,
|
||||
.prose h6 {
|
||||
font-weight: 600;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.prose h1 tt,
|
||||
.prose h1 code,
|
||||
.prose h2 tt,
|
||||
.prose h2 code,
|
||||
.prose h3 tt,
|
||||
.prose h3 code,
|
||||
.prose h4 tt,
|
||||
.prose h4 code,
|
||||
.prose h5 tt,
|
||||
.prose h5 code,
|
||||
.prose h6 tt,
|
||||
.prose h6 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
.prose h1 {
|
||||
border-bottom: 1px solid var(--color-border2);
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 2em;
|
||||
}
|
||||
.prose h2 {
|
||||
border-bottom: 1px solid var(--color-border2);
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.prose h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
.prose h4 {
|
||||
font-size: 1em;
|
||||
}
|
||||
.prose h5 {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.prose h6 {
|
||||
color: var(--color-text-light);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.prose p,
|
||||
.prose blockquote,
|
||||
.prose details,
|
||||
.prose ul,
|
||||
.prose ol,
|
||||
.prose dl,
|
||||
.prose table,
|
||||
.prose pre {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.prose hr {
|
||||
background-color: var(--color-secondary);
|
||||
border: 0;
|
||||
height: 4px;
|
||||
margin: 16px 0;
|
||||
padding: 0;
|
||||
}
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
padding-left: 2em;
|
||||
}
|
||||
.prose ul ul,
|
||||
.prose ul ol,
|
||||
.prose ol ol,
|
||||
.prose ol ul {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose ol ol,
|
||||
.prose ul ol {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
.prose li > p {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.prose li + li {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
.prose dl {
|
||||
padding: 0;
|
||||
}
|
||||
.prose dl dt {
|
||||
font-size: 1em;
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
margin-top: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
.prose dl dd {
|
||||
margin-bottom: 16px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.prose blockquote {
|
||||
color: var(--color-text-light);
|
||||
border-left: 4px solid var(--color-secondary);
|
||||
margin-left: 0;
|
||||
padding: 0 15px;
|
||||
}
|
||||
.prose blockquote > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.prose blockquote > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose table {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.prose table th {
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose table th,
|
||||
.prose table td {
|
||||
border: 1px solid var(--color-secondary) !important;
|
||||
padding: 6px 13px !important;
|
||||
}
|
||||
.prose table tr {
|
||||
border-top: 1px solid var(--color-secondary);
|
||||
}
|
||||
.prose table tr:nth-child(2n) {
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
.prose img,
|
||||
.prose video {
|
||||
box-sizing: initial;
|
||||
max-width: 100%;
|
||||
}
|
||||
.prose img[align="right"],
|
||||
.prose video[align="right"] {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.prose img[align="left"],
|
||||
.prose video[align="left"] {
|
||||
padding-right: 28px;
|
||||
}
|
||||
.prose code {
|
||||
white-space: break-spaces;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
padding: 0.2em 0.4em;
|
||||
font-size: 85%;
|
||||
}
|
||||
.prose code br {
|
||||
display: none;
|
||||
}
|
||||
.prose pre {
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 16px;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
}
|
||||
.prose pre code {
|
||||
padding: 0;
|
||||
}
|
||||
.prose pre code:before,
|
||||
.prose pre code:after {
|
||||
content: normal;
|
||||
}
|
||||
.prose .ui.list .list,
|
||||
.prose ol.ui.list ol,
|
||||
.prose ul.ui.list ul {
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
/* theme "Monokai++" generated by syntect */
|
||||
.entity.name.function.preprocessor,
|
||||
.meta.preprocessor.macro,
|
||||
.storage.modifier.import,
|
||||
.storage.type.generic,
|
||||
.variable.parameter,
|
||||
.punctuation.section.class.begin,
|
||||
.punctuation.section.class.end {
|
||||
color: #cccccc;
|
||||
}
|
||||
.invalid {
|
||||
background-color: #e62a19;
|
||||
}
|
||||
.comment {
|
||||
color: #696d70;
|
||||
}
|
||||
.string,
|
||||
.string.quoted,
|
||||
.punctuation.definition.string.begin,
|
||||
.punctuation.definition.string.end {
|
||||
color: #e6db74;
|
||||
}
|
||||
.string.regexp {
|
||||
color: #49e0fd;
|
||||
}
|
||||
.constant.language,
|
||||
.constant.numeric,
|
||||
.support.variable.magic {
|
||||
color: #ae81ff;
|
||||
}
|
||||
.constant.character,
|
||||
.constant.other.placeholder,
|
||||
.support.other.escape.special.regexp {
|
||||
color: #e62a19;
|
||||
}
|
||||
.constant.other {
|
||||
color: #fd971f;
|
||||
}
|
||||
.entity.name.variable.property,
|
||||
.keyword,
|
||||
.meta.preprocessor {
|
||||
color: #f92672;
|
||||
}
|
||||
.storage,
|
||||
.support.constant,
|
||||
.punctuation.section.class {
|
||||
color: #49e0fd;
|
||||
}
|
||||
.keyword.type,
|
||||
.storage.type,
|
||||
.support.class,
|
||||
.support.type,
|
||||
.entity.name.type {
|
||||
color: #2be98a;
|
||||
}
|
||||
.variable.language,
|
||||
.variable.parameter.function.language.special,
|
||||
.variable.other.member,
|
||||
.variable.other.readwrite.member,
|
||||
.entity.other.attribute-name,
|
||||
.variable.parameter.function-call {
|
||||
color: #fd971f;
|
||||
}
|
||||
.punctuation.accessor,
|
||||
.punctuation.section.embedded,
|
||||
.punctuation.separator,
|
||||
.punctuation.definition.attribute,
|
||||
.storage.type.function.arrow,
|
||||
.punctuation.definition.template-expression,
|
||||
.punctuation.definition.template-expression.begin,
|
||||
.punctuation.definition.template-expression.end,
|
||||
.punctuation.template-string.element.begin,
|
||||
.punctuation.template-string.element.end {
|
||||
color: #f92672;
|
||||
}
|
||||
.punctuation.separator.parameters {
|
||||
color: #fd971f;
|
||||
}
|
||||
.entity.name.tag {
|
||||
color: #f92672;
|
||||
}
|
||||
.entity.name.function,
|
||||
.support.function,
|
||||
.variable.function {
|
||||
color: #b0ec38;
|
||||
}
|
||||
.markup.heading {
|
||||
color: #f92672;
|
||||
font-weight: bold;
|
||||
}
|
||||
.markup.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
.markup.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
.markup.underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.markup.quote {
|
||||
color: #696d70;
|
||||
}
|
||||
.markup.inline,
|
||||
.markup.raw.inline {
|
||||
color: #ae81ff;
|
||||
}
|
||||
.keyword.operator.dereference.java,
|
||||
.meta.preprocessor.haskell,
|
||||
.punctuation.separator.java,
|
||||
.meta.group.js,
|
||||
.meta.group.go,
|
||||
.punctuation.section.class.begin.python,
|
||||
.support.variable.dom.js,
|
||||
.constant.character.brace,
|
||||
.constant.character.end,
|
||||
.constant.character.paren,
|
||||
.constant.character.quote,
|
||||
.support.class.js,
|
||||
.punctuation.section.group.begin.js,
|
||||
.punctuation.section.group.end.js,
|
||||
.meta.template.expression,
|
||||
.meta.group.braces,
|
||||
.source.groovy.embedded.source,
|
||||
.punctuation.section.class.end.groovy,
|
||||
.variable.other.bracket.shell,
|
||||
.variable.other.readwrite.shell,
|
||||
.meta.group.expansion.command.parens.shell,
|
||||
.variable.other.normal.shell,
|
||||
.string.interpolated.dollar.shell,
|
||||
.meta.function.shell .punctuation.section.parens.begin.shell,
|
||||
.meta.function.shell .punctuation.section.parens.end.shell,
|
||||
.string.other.math.shell {
|
||||
color: #cccccc;
|
||||
}
|
||||
.constant.other.symbol.prolog,
|
||||
.support.function.be.latex,
|
||||
.support.function.general.tex,
|
||||
.support.function.section.latex,
|
||||
.punctuation.dollar.js,
|
||||
.punctuation.separator.parameters.python,
|
||||
.support.function.definition.latex,
|
||||
.constant.language.module.events,
|
||||
.constant.language.module.http,
|
||||
.constant.language.directive.module.main,
|
||||
.constant.language.directive.module.events,
|
||||
.constant.language.directive.module.http,
|
||||
.variable.language.this.js,
|
||||
.variable.parameter.option.shell,
|
||||
.punctuation.definition.variable.shell,
|
||||
.punctuation.section.expansion.parameter.begin.shell,
|
||||
.punctuation.section.expansion.parameter.end.shell,
|
||||
.punctuation.section.parens.begin.shell,
|
||||
.punctuation.section.parens.end.shell,
|
||||
.string.interpolated.dollar.shell .punctuation.definition.string.begin.shell,
|
||||
.string.interpolated.dollar.shell .punctuation.definition.string.end.shell,
|
||||
.string.other.math.shell .punctuation.definition.string.begin.shell,
|
||||
.string.other.math.shell .punctuation.definition.string.end.shell,
|
||||
.variable.language.special.self.python,
|
||||
.variable.parameter.function.language.special.self.python {
|
||||
color: #f92672;
|
||||
}
|
||||
.entity.name.type.go,
|
||||
.entity.name.type.namespace.php,
|
||||
.meta.import.scala,
|
||||
.punctuation.separator.inheritance.php,
|
||||
.storage.type.js,
|
||||
.support.other.module.haskell,
|
||||
.support.other.namespace.use.php,
|
||||
.variable.other.constant.ruby,
|
||||
.entity.name.section.puppet,
|
||||
.entity.name.function.decorator.python,
|
||||
.keyword.other.rust {
|
||||
color: #49e0fd;
|
||||
}
|
||||
.keyword.control.def.ruby,
|
||||
.keyword.declaration.scala,
|
||||
.keyword.declaration.stable.scala,
|
||||
.keyword.declaration.volatile.scala,
|
||||
.keyword.other.fn.rust,
|
||||
.meta.structure.dictionary.key.json,
|
||||
.storage.class.std.rust {
|
||||
color: #2be98a;
|
||||
}
|
||||
.meta.function-call.object.php,
|
||||
.meta.function-call.static.php,
|
||||
.variable.other.makefile,
|
||||
.variable.other.prolog,
|
||||
.variable.other.property.js,
|
||||
.support.variable.property.dom.js,
|
||||
.meta.property.object.js,
|
||||
.support.variable.property.js,
|
||||
.variable.other.object.property.js,
|
||||
.variable.other.property.cpp,
|
||||
.meta.attribute.python {
|
||||
color: #fd971f;
|
||||
}
|
||||
.meta.method.groovy,
|
||||
.punctuation.definition.logical-expression.shell,
|
||||
.meta.function-call.generic.python {
|
||||
color: #b0ec38;
|
||||
}
|
||||
.constant.other.boolean.toml {
|
||||
color: #ae81ff;
|
||||
}
|
||||
.string.other.link.title.markdown,
|
||||
.string.other.link.description.markdown {
|
||||
color: #49e0fd;
|
||||
}
|
||||
.beginning.punctuation.definition.list.markdown,
|
||||
.punctuation.definition.list_item.markdown,
|
||||
.punctuation.definition.list.markdown,
|
||||
.punctuation.definition.heading.markdown,
|
||||
.punctuation.definition.bold.markdown,
|
||||
.punctuation.definition.italic.markdown,
|
||||
.punctuation.definition.string.begin.markdown,
|
||||
.punctuation.definition.string.end.markdown,
|
||||
.punctuation.definition.bold.begin.markdown,
|
||||
.punctuation.definition.bold.end.markdown,
|
||||
.punctuation.definition.italic.begin.markdown,
|
||||
.punctuation.definition.italic.end.markdown,
|
||||
.punctuation.definition.heading.begin.markdown,
|
||||
.punctuation.definition.heading.end.markdown,
|
||||
.punctuation.definition.raw.begin.markdown,
|
||||
.punctuation.definition.raw.end.markdown,
|
||||
.punctuation.definition.metadata.markdown,
|
||||
.punctuation.definition.raw.markdown,
|
||||
.markup.underline.link.image.markdown,
|
||||
.markup.underline.link.markdown {
|
||||
color: #696d70;
|
||||
}
|
||||
.markup.deleted.diff {
|
||||
color: #f92672;
|
||||
}
|
||||
.markup.inserted.diff {
|
||||
color: #2be98a;
|
||||
}
|
||||
.meta.diff.range.unified {
|
||||
color: #ae81ff;
|
||||
}
|
||||
.markup.deleted.git_gutter {
|
||||
color: #f92672;
|
||||
}
|
||||
.markup.inserted.git_gutter {
|
||||
color: #2be98a;
|
||||
}
|
||||
.markup.changed.git_gutter {
|
||||
color: #ae81ff;
|
||||
}
|
||||
.markup.ignored.git_gutter {
|
||||
color: #696d70;
|
||||
}
|
||||
.markup.untracked.git_gutter {
|
||||
color: #696d70;
|
||||
}
|
||||
|
||||
.junit {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.junit {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#preview-margin {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.junit > div:not(:last-child) {
|
||||
border-right: solid 1px var(--color-border);
|
||||
}
|
||||
#junit-suites,
|
||||
#junit-cases {
|
||||
min-width: 300px;
|
||||
width: 300px;
|
||||
}
|
||||
#junit-preview {
|
||||
flex-grow: 1;
|
||||
margin-bottom: 40px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
#junit-preview h2 {
|
||||
border-bottom: 2px solid var(--color-status);
|
||||
}
|
||||
|
||||
#junit-preview h2 i {
|
||||
color: var(--color-status);
|
||||
}
|
||||
|
||||
.junit ul {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.junit ul > li,
|
||||
.colsubtitle {
|
||||
border-bottom: 1px dashed var(--color-border2);
|
||||
}
|
||||
|
||||
.colsubtitle > button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 2px 0;
|
||||
padding: 4px 8px;
|
||||
border: 2px solid var(--color-status, var(--color-btn));
|
||||
}
|
||||
.colsubtitle button.active {
|
||||
background-color: var(--color-status, var(--color-btn));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.coltitle {
|
||||
font-size: 14px;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.colsubtitle {
|
||||
padding: 0 8px 8px 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.junit li > button {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
background-color: transparent;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.junit li button.active {
|
||||
text-decoration: underline;
|
||||
color: var(--color-a-hov);
|
||||
}
|
||||
|
||||
#junit-cases.filtered li > button > span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.badges > *:not(:last-child):after {
|
||||
content: "•";
|
||||
margin: 0 0.4em;
|
||||
}
|
||||
|
||||
.pvcontent {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.junit li[data-status="success"] {
|
||||
background-color: #00800035;
|
||||
}
|
||||
.junit li[data-status="failure"] {
|
||||
background-color: #a6000035;
|
||||
}
|
||||
.junit li[data-status="error"] {
|
||||
background-color: #67000035;
|
||||
}
|
||||
.junit li[data-status="skipped"] {
|
||||
background-color: #33333335;
|
||||
}
|
||||
.junit li[data-status="flaky"] {
|
||||
background-color: #d3641a35;
|
||||
}
|
||||
|
||||
[data-status="success"] {
|
||||
--color-status: #008000;
|
||||
}
|
||||
[data-status="failure"] {
|
||||
--color-status: #a60000;
|
||||
}
|
||||
[data-status="error"] {
|
||||
--color-status: #670000;
|
||||
}
|
||||
[data-status="flaky"] {
|
||||
--color-status: #d3641a;
|
||||
}
|
||||
[data-status="skipped"] {
|
||||
--color-status: #333;
|
||||
}
|
||||
|
||||
/* Icons from https://css.gg */
|
||||
.gg-check-o {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
transform: scale(var(--ggs, 1));
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid;
|
||||
border-radius: 100px;
|
||||
}
|
||||
.gg-check-o::after {
|
||||
content: "";
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: -1px;
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
border-color: currentColor;
|
||||
border-width: 0 2px 2px 0;
|
||||
border-style: solid;
|
||||
transform-origin: bottom left;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.gg-close-o {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
transform: scale(var(--ggs, 1));
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid;
|
||||
border-radius: 40px;
|
||||
}
|
||||
|
||||
.gg-close-o::after,
|
||||
.gg-close-o::before {
|
||||
content: "";
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
transform: rotate(45deg);
|
||||
border-radius: 5px;
|
||||
top: 8px;
|
||||
left: 3px;
|
||||
}
|
||||
|
||||
.gg-close-o::after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.gg-block {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
transform: scale(var(--ggs, 1));
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.gg-block::before {
|
||||
content: "";
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
border-radius: 5px;
|
||||
transform: rotate(-45deg);
|
||||
top: 5px;
|
||||
left: 1px;
|
||||
}
|
||||
|
||||
.gg-danger {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
transform: scale(var(--ggs, 1));
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid;
|
||||
border-radius: 40px;
|
||||
}
|
||||
|
||||
.gg-danger::after,
|
||||
.gg-danger::before {
|
||||
content: "";
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
border-radius: 3px;
|
||||
width: 2px;
|
||||
background: currentColor;
|
||||
left: 7px;
|
||||
}
|
||||
|
||||
.gg-danger::after {
|
||||
top: 2px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.gg-danger::before {
|
||||
height: 2px;
|
||||
bottom: 2px;
|
||||
}
|
BIN
resources/content.css.gz
Normal file
Before Width: | Height: | Size: 268 B After Width: | Height: | Size: 766 B |
BIN
resources/screenshotCode.png
Normal file
After Width: | Height: | Size: 43 KiB |
BIN
resources/screenshotFiles.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
resources/screenshotJUnit.png
Normal file
After Width: | Height: | Size: 47 KiB |
BIN
resources/screenshotPrComment.png
Normal file
After Width: | Height: | Size: 20 KiB |
229
resources/style.css
Normal file
|
@ -0,0 +1,229 @@
|
|||
/* Stylesheet for all artifactview pages */
|
||||
* {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
--color-secondary: #dedede;
|
||||
--color-text: #000;
|
||||
--color-text-light: #888;
|
||||
--color-border: #ccc;
|
||||
--color-border2: #bbb;
|
||||
--color-btn: #006ed3;
|
||||
--color-a: #006ed3;
|
||||
--color-a-hov: #319cff;
|
||||
}
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
text-rendering: optimizespeed;
|
||||
background-color: #f5f5f5;
|
||||
color: var(--color-text);
|
||||
}
|
||||
a {
|
||||
color: var(--color-a);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover, a.selected {
|
||||
color: var(--color-a-hov);
|
||||
}
|
||||
header, #summary, .content {
|
||||
padding: 0 20px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1em;
|
||||
padding-top: 25px;
|
||||
padding-bottom: 15px;
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #999;
|
||||
}
|
||||
header h1 a {
|
||||
color: var(--color-text);
|
||||
}
|
||||
header h1 .sep {
|
||||
margin: 0 0.2em;
|
||||
}
|
||||
footer a:hover,
|
||||
header h1 a:hover,
|
||||
a.selected {
|
||||
text-decoration: underline;
|
||||
}
|
||||
main {
|
||||
display: block;
|
||||
}
|
||||
#summary, #summary > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
}
|
||||
.metadata {
|
||||
font-size: 12px;
|
||||
font-family: Verdana, sans-serif;
|
||||
border-bottom: 1px solid #9c9c9c;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
#filter {
|
||||
padding: 4px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
#list {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
#list tr {
|
||||
border-bottom: 1px dashed var(--color-border2);
|
||||
}
|
||||
#list tbody tr:hover {
|
||||
background-color: #ffffec;
|
||||
}
|
||||
#list td,
|
||||
#list th {
|
||||
text-align: left;
|
||||
padding: 10px 0;
|
||||
}
|
||||
#list th {
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
font-size: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#list th a {
|
||||
color: var(--color-text);
|
||||
}
|
||||
#list th svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
#list td {
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
#list td:nth-child(1),
|
||||
#list th:nth-child(1) {
|
||||
padding-left: 20px;
|
||||
width: 80%;
|
||||
}
|
||||
#list td:nth-child(2),
|
||||
#list th:nth-child(2) {
|
||||
text-align: right;
|
||||
padding: 0 20px;
|
||||
}
|
||||
#list td:nth-child(3),
|
||||
#list th:nth-child(3) {
|
||||
text-align: right;
|
||||
padding-right: 20px;
|
||||
}
|
||||
#list td:nth-child(1) svg {
|
||||
position: absolute;
|
||||
}
|
||||
#list td .goup,
|
||||
#list td .name {
|
||||
margin-left: 1.75em;
|
||||
word-break: break-all;
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.query-input {
|
||||
color: inherit;
|
||||
font-size: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
button {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
background-color: unset;
|
||||
color: unset;
|
||||
}
|
||||
.btn {
|
||||
background-color: var(--color-btn);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
button:hover {
|
||||
filter: brightness(80%);
|
||||
}
|
||||
button:active {
|
||||
filter: brightness(70%);
|
||||
}
|
||||
footer {
|
||||
padding: 40px 20px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
p {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
align-items: center;
|
||||
}
|
||||
.input-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
.center {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
.light {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
td:nth-child(1) {
|
||||
width: auto;
|
||||
}
|
||||
td:nth-child(2),
|
||||
th:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
#filter {
|
||||
max-width: 100px;
|
||||
}
|
||||
}
|
||||
.expired {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
* {
|
||||
--color-secondary: #082437;
|
||||
--color-text: #ddd;
|
||||
--color-border: #212121;
|
||||
--color-border2: #333;
|
||||
--color-a: #009dff;
|
||||
--color-a-hov: #62b2fd;
|
||||
}
|
||||
body {
|
||||
background-color: #101010;
|
||||
}
|
||||
header {
|
||||
background-color: #151515;
|
||||
}
|
||||
.query-input {
|
||||
background-color: #151515;
|
||||
}
|
||||
#list tbody tr:hover {
|
||||
background-color: #252525;
|
||||
}
|
||||
#filter {
|
||||
background-color: #151515;
|
||||
color: #ffffff;
|
||||
border: 1px solid #212121;
|
||||
}
|
||||
.metadata {
|
||||
border-bottom: 1px solid #212121;
|
||||
}
|
||||
}
|
BIN
resources/style.css.gz
Normal file
858
src/app.rs
|
@ -1,27 +1,31 @@
|
|||
//! API-Client to fetch CI artifacts from Github and Forgejo
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use futures_lite::StreamExt;
|
||||
use http::header;
|
||||
use http::{header, Method};
|
||||
use once_cell::sync::Lazy;
|
||||
use quick_cache::sync::Cache as QuickCache;
|
||||
use regex::Regex;
|
||||
use reqwest::{Client, ClientBuilder, IntoUrl, RequestBuilder, Response, Url};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use secrecy::ExposeSecret;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::{fs::File, io::AsyncWriteExt};
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
error::{Error, Result},
|
||||
query::{ArtifactQuery, QueryData},
|
||||
query::{ArtifactQuery, Query, QueryRef, RunQuery},
|
||||
};
|
||||
|
||||
pub struct ArtifactApi {
|
||||
http: Client,
|
||||
cfg: Config,
|
||||
qc: QuickCache<String, Vec<Artifact>>,
|
||||
user_ids: QuickCache<String, u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Artifact {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
|
@ -35,7 +39,7 @@ pub struct Artifact {
|
|||
pub user_download_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GithubArtifact {
|
||||
id: u64,
|
||||
name: String,
|
||||
|
@ -44,24 +48,24 @@ struct GithubArtifact {
|
|||
archive_download_url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ForgejoArtifact {
|
||||
name: String,
|
||||
size: u64,
|
||||
status: ForgejoArtifactStatus,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ArtifactsWrap<T> {
|
||||
artifacts: Vec<T>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum ForgejoArtifactStatus {
|
||||
Completed,
|
||||
|
@ -69,7 +73,7 @@ enum ForgejoArtifactStatus {
|
|||
}
|
||||
|
||||
impl GithubArtifact {
|
||||
fn into_artifact<T>(self, query: &QueryData<T>) -> Artifact {
|
||||
fn into_artifact(self, query: QueryRef<'_>) -> Artifact {
|
||||
Artifact {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
|
@ -85,7 +89,7 @@ impl GithubArtifact {
|
|||
}
|
||||
|
||||
impl ForgejoArtifact {
|
||||
fn into_artifact<T>(self, id: u64, query: &QueryData<T>) -> Artifact {
|
||||
fn into_artifact(self, id: u64, query: QueryRef<'_>) -> Artifact {
|
||||
Artifact {
|
||||
download_url: format!(
|
||||
"https://{}/{}/{}/actions/runs/{}/artifacts/{}",
|
||||
|
@ -100,6 +104,154 @@ impl ForgejoArtifact {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WorkflowRun {
|
||||
pub head_sha: String,
|
||||
pub from_pr: bool,
|
||||
pub pr_number: Option<u64>,
|
||||
pub date_started: Option<OffsetDateTime>,
|
||||
pub done: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ForgejoWorkflowRun {
|
||||
state: ForgejoWorkflowState,
|
||||
logs: ForgejoWorkflowLogs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ForgejoWorkflowState {
|
||||
run: ForgejoWorkflowStateRun,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ForgejoWorkflowStateRun {
|
||||
done: bool,
|
||||
commit: ForgejoWorkflowCommit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ForgejoWorkflowCommit {
|
||||
link: String,
|
||||
branch: ForgejoWorkflowBranch,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ForgejoWorkflowBranch {
|
||||
link: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ForgejoWorkflowLogs {
|
||||
steps_log: Vec<ForgejoWorkflowLogStep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ForgejoWorkflowLogStep {
|
||||
started: i64,
|
||||
lines: Vec<LogMessage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LogMessage {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IdEntity {
|
||||
id: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Comment {
|
||||
pub id: u64,
|
||||
pub body: String,
|
||||
user: IdEntity,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CommentBody<'a> {
|
||||
body: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PullRequest {
|
||||
pub head: Commit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Commit {
|
||||
pub sha: String,
|
||||
}
|
||||
|
||||
const GITHUB_ACCEPT: &str = "application/vnd.github+json";
|
||||
const COMMENT_TAG_PATTERN: &str = "<!-- Artifactview -->";
|
||||
|
||||
impl TryFrom<ForgejoWorkflowRun> for WorkflowRun {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: ForgejoWorkflowRun) -> Result<Self> {
|
||||
static RE_COMMIT_SHA: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r#"^/[\w\-\.]+/[\w\-\.]+/commit/([a-f\d]+)$"#).unwrap());
|
||||
static RE_PULL_ID: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r#"^/[\w\-\.]+/[\w\-\.]+/pulls/(\d+)$"#).unwrap());
|
||||
|
||||
let from_pr = value
|
||||
.logs
|
||||
.steps_log
|
||||
.first()
|
||||
.and_then(|l| l.lines.first())
|
||||
.map(|l| l.message.contains("be triggered by event: pull_request"))
|
||||
.unwrap_or(true);
|
||||
|
||||
Ok(Self {
|
||||
head_sha: RE_COMMIT_SHA
|
||||
.captures(&value.state.run.commit.link)
|
||||
.map(|cap| cap[1].to_string())
|
||||
.ok_or(Error::Other(
|
||||
"could not parse workflow run commit sha".into(),
|
||||
))?,
|
||||
from_pr,
|
||||
pr_number: if from_pr {
|
||||
RE_PULL_ID
|
||||
.captures(&value.state.run.commit.branch.link)
|
||||
.and_then(|cap| cap[1].parse().ok())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
date_started: value
|
||||
.logs
|
||||
.steps_log
|
||||
.first()
|
||||
.and_then(|l| OffsetDateTime::from_unix_timestamp(l.started).ok()),
|
||||
done: value.state.run.done,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitHubWorkflowRun {
|
||||
head_sha: String,
|
||||
event: String,
|
||||
conclusion: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
run_started_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
impl From<GitHubWorkflowRun> for WorkflowRun {
|
||||
fn from(value: GitHubWorkflowRun) -> Self {
|
||||
Self {
|
||||
head_sha: value.head_sha,
|
||||
from_pr: value.event == "pull_request",
|
||||
pr_number: None,
|
||||
date_started: value.run_started_at,
|
||||
done: value.conclusion.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtifactApi {
|
||||
pub fn new(cfg: Config) -> Self {
|
||||
Self {
|
||||
|
@ -112,29 +264,40 @@ impl ArtifactApi {
|
|||
.build()
|
||||
.unwrap(),
|
||||
qc: QuickCache::new(cfg.load().mem_cache_size),
|
||||
user_ids: QuickCache::new(50),
|
||||
cfg,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list<T>(&self, query: &QueryData<T>) -> Result<Vec<Artifact>> {
|
||||
let subdomain = query.subdomain_with_artifact(None)?;
|
||||
self.qc
|
||||
.get_or_insert_async(&subdomain, async {
|
||||
if query.is_github() {
|
||||
self.list_github(query).await
|
||||
#[tracing::instrument(level = "error", skip_all)]
|
||||
pub async fn list(&self, query: &RunQuery, cached: bool) -> Result<Vec<Artifact>> {
|
||||
let cache_key = query.cache_key();
|
||||
let fut = async {
|
||||
let res = if query.is_github() {
|
||||
self.list_github(query.as_ref()).await
|
||||
} else {
|
||||
self.list_forgejo(query).await
|
||||
self.list_forgejo(query.as_ref()).await
|
||||
};
|
||||
if res.as_ref().is_ok_and(|v| v.is_empty()) {
|
||||
Err(Error::NotFound("artifact".into()))
|
||||
} else {
|
||||
res
|
||||
}
|
||||
};
|
||||
if cached {
|
||||
self.qc.get_or_insert_async(&cache_key, fut).await
|
||||
} else {
|
||||
fut.await
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "error", skip_all)]
|
||||
pub async fn fetch(&self, query: &ArtifactQuery) -> Result<Artifact> {
|
||||
if query.is_github() {
|
||||
self.fetch_github(query).await
|
||||
} else {
|
||||
// Forgejo currently has no API for fetching single artifacts
|
||||
let mut artifacts = self.list_forgejo(query).await?;
|
||||
let mut artifacts = self.list_forgejo(query.as_ref()).await?;
|
||||
|
||||
let i = usize::try_from(query.artifact)?;
|
||||
if i == 0 || i > artifacts.len() {
|
||||
|
@ -144,6 +307,7 @@ impl ArtifactApi {
|
|||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "error", skip_all)]
|
||||
pub async fn download(&self, artifact: &Artifact, path: &Path) -> Result<()> {
|
||||
if artifact.expired {
|
||||
return Err(Error::Expired);
|
||||
|
@ -172,7 +336,7 @@ impl ArtifactApi {
|
|||
|
||||
let url = Url::parse(&artifact.download_url)?;
|
||||
let req = if url.domain() == Some("api.github.com") {
|
||||
self.get_github(url)
|
||||
self.get_github_any(url)
|
||||
} else {
|
||||
self.http.get(url)
|
||||
};
|
||||
|
@ -200,15 +364,14 @@ impl ArtifactApi {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_forgejo<T>(&self, query: &QueryData<T>) -> Result<Vec<Artifact>> {
|
||||
async fn list_forgejo(&self, query: QueryRef<'_>) -> Result<Vec<Artifact>> {
|
||||
let url = format!(
|
||||
"https://{}/{}/{}/actions/runs/{}/artifacts",
|
||||
query.host, query.user, query.repo, query.run
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(url)
|
||||
.get_forgejo(url)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
|
@ -225,16 +388,14 @@ impl ArtifactApi {
|
|||
Ok(artifacts)
|
||||
}
|
||||
|
||||
async fn list_github<T>(&self, query: &QueryData<T>) -> Result<Vec<Artifact>> {
|
||||
async fn list_github(&self, query: QueryRef<'_>) -> Result<Vec<Artifact>> {
|
||||
let url = format!(
|
||||
"https://api.github.com/repos/{}/{}/actions/runs/{}/artifacts",
|
||||
query.user, query.repo, query.run
|
||||
);
|
||||
|
||||
let resp = Self::handle_github_error(self.get_github(url).send().await?)
|
||||
.await?
|
||||
.json::<ArtifactsWrap<GithubArtifact>>()
|
||||
.await?;
|
||||
let resp =
|
||||
Self::send_api_req::<ArtifactsWrap<GithubArtifact>>(self.get_github(url)).await?;
|
||||
|
||||
Ok(resp
|
||||
.artifacts
|
||||
|
@ -249,52 +410,362 @@ impl ArtifactApi {
|
|||
query.user, query.repo, query.artifact
|
||||
);
|
||||
|
||||
let artifact = Self::handle_github_error(self.get_github(url).send().await?)
|
||||
.await?
|
||||
.json::<GithubArtifact>()
|
||||
.await?;
|
||||
Ok(artifact.into_artifact(query))
|
||||
let artifact = Self::send_api_req::<GithubArtifact>(self.get_github(url)).await?;
|
||||
Ok(artifact.into_artifact(query.as_ref()))
|
||||
}
|
||||
|
||||
async fn handle_github_error(resp: Response) -> Result<Response> {
|
||||
async fn send_api_req_empty(req: RequestBuilder) -> Result<Response> {
|
||||
let resp = req.send().await?;
|
||||
if let Err(e) = resp.error_for_status_ref() {
|
||||
let status = resp.status();
|
||||
let msg = resp.json::<ApiError>().await.ok();
|
||||
Err(Error::HttpClient(
|
||||
msg.map(|msg| msg.message).unwrap_or(e.to_string()).into(),
|
||||
status,
|
||||
))
|
||||
let msg_str = msg.map(|msg| msg.message).unwrap_or(e.to_string()).into();
|
||||
tracing::error!("API error: {msg_str}");
|
||||
Err(Error::HttpClient(msg_str, status))
|
||||
} else {
|
||||
Ok(resp)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_github<U: IntoUrl>(&self, url: U) -> RequestBuilder {
|
||||
async fn send_api_req<T: DeserializeOwned>(req: RequestBuilder) -> Result<T> {
|
||||
Ok(Self::send_api_req_empty(req).await?.json().await?)
|
||||
}
|
||||
|
||||
fn get_github_any<U: IntoUrl>(&self, url: U) -> RequestBuilder {
|
||||
let mut builder = self.http.get(url);
|
||||
|
||||
if let Some(github_token) = &self.cfg.load().github_token {
|
||||
builder = builder.header(header::AUTHORIZATION, format!("Bearer {github_token}"));
|
||||
builder = builder.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {}", github_token.expose_secret()),
|
||||
);
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
fn get_github<U: IntoUrl>(&self, url: U) -> RequestBuilder {
|
||||
self.get_github_any(url)
|
||||
.header(header::ACCEPT, GITHUB_ACCEPT)
|
||||
}
|
||||
|
||||
/// Authorized GitHub request
|
||||
fn req_github<U: IntoUrl>(&self, method: Method, url: U) -> Result<RequestBuilder> {
|
||||
Ok(self
|
||||
.http
|
||||
.request(method, url)
|
||||
.header(header::ACCEPT, GITHUB_ACCEPT)
|
||||
.header(header::CONTENT_TYPE, GITHUB_ACCEPT)
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!(
|
||||
"Bearer {}",
|
||||
self.cfg
|
||||
.load()
|
||||
.github_token
|
||||
.as_ref()
|
||||
.map(ExposeSecret::expose_secret)
|
||||
.ok_or(Error::Other("GitHub token required".into()))?
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn get_forgejo<U: IntoUrl>(&self, url: U) -> RequestBuilder {
|
||||
self.http
|
||||
.get(url)
|
||||
.header(header::ACCEPT, mime::APPLICATION_JSON.essence_str())
|
||||
}
|
||||
|
||||
/// Authorized Forgejo request
|
||||
fn req_forgejo<U: IntoUrl>(&self, method: Method, url: U) -> Result<RequestBuilder> {
|
||||
let u = url.into_url()?;
|
||||
let host = u.host_str().ok_or(Error::InvalidUrl)?;
|
||||
let token = self
|
||||
.cfg
|
||||
.load()
|
||||
.forgejo_tokens
|
||||
.get(host)
|
||||
.ok_or_else(|| Error::Other(format!("Forgejo token for {host} required").into()))?
|
||||
.expose_secret();
|
||||
Ok(self
|
||||
.http
|
||||
.request(method, u)
|
||||
.header(header::ACCEPT, mime::APPLICATION_JSON.essence_str())
|
||||
.header(header::CONTENT_TYPE, mime::APPLICATION_JSON.essence_str())
|
||||
.header(header::AUTHORIZATION, format!("token {token}")))
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "error", skip_all)]
|
||||
pub async fn workflow_run(&self, query: &RunQuery) -> Result<WorkflowRun> {
|
||||
if query.is_github() {
|
||||
self.workflow_run_github(query).await
|
||||
} else {
|
||||
self.workflow_run_forgejo(query).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn workflow_run_forgejo(&self, query: &RunQuery) -> Result<WorkflowRun> {
|
||||
// Since the workflow needs to be fetched with a POST request, we need a CSRF token
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("https://{}", query.host))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let mut i_like_gitea = None;
|
||||
let mut csrf = None;
|
||||
for (k, v) in resp
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.into_iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.filter_map(|v| v.split(';').next())
|
||||
.filter_map(|v| v.split_once('='))
|
||||
{
|
||||
match k {
|
||||
"i_like_gitea" => i_like_gitea = Some(v),
|
||||
"_csrf" => csrf = Some(v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let i_like_gitea =
|
||||
i_like_gitea.ok_or(Error::Other("missing header: i_like_gitea".into()))?;
|
||||
let csrf = csrf.ok_or(Error::Other("missing header: _csrf".into()))?;
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!(
|
||||
"https://{}/{}/{}/actions/runs/{}/jobs/0",
|
||||
query.host, query.user, query.repo, query.run
|
||||
))
|
||||
.header(header::CONTENT_TYPE, mime::APPLICATION_JSON.essence_str())
|
||||
.header(header::COOKIE, format!("i_like_gitea={i_like_gitea}"))
|
||||
.header("x-csrf-token", csrf)
|
||||
.body(r#"{"logCursors":[{"step":0,"cursor":null,"expanded":true}]}"#)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let run: WorkflowRun = resp.json::<ForgejoWorkflowRun>().await?.try_into()?;
|
||||
Ok(run)
|
||||
}
|
||||
|
||||
async fn workflow_run_github(&self, query: &RunQuery) -> Result<WorkflowRun> {
|
||||
let run = Self::send_api_req::<GitHubWorkflowRun>(self.get_github(format!(
|
||||
"https://api.github.com/repos/{}/{}/actions/runs/{}",
|
||||
query.user, query.repo, query.run
|
||||
)))
|
||||
.await?;
|
||||
Ok(run.into())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "error", skip_all)]
|
||||
pub async fn add_comment(
|
||||
&self,
|
||||
query: QueryRef<'_>,
|
||||
issue_id: u64,
|
||||
content: &str,
|
||||
old_comment_id: Option<u64>,
|
||||
recreate: bool,
|
||||
) -> Result<u64> {
|
||||
let body = format!("{COMMENT_TAG_PATTERN}\n{content}");
|
||||
if query.is_github() {
|
||||
self.add_comment_github(query, issue_id, &body, old_comment_id, recreate)
|
||||
.await
|
||||
} else {
|
||||
self.add_comment_forgejo(query, issue_id, &body, old_comment_id, recreate)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_comment_forgejo(
|
||||
&self,
|
||||
query: QueryRef<'_>,
|
||||
issue_id: u64,
|
||||
body: &str,
|
||||
old_comment_id: Option<u64>,
|
||||
recreate: bool,
|
||||
) -> Result<u64> {
|
||||
if let Some(old_comment_id) = old_comment_id {
|
||||
let url = format!(
|
||||
"https://{}/api/v1/repos/{}/{}/issues/comments/{}",
|
||||
query.host, query.user, query.repo, old_comment_id
|
||||
);
|
||||
if recreate {
|
||||
Self::send_api_req_empty(self.req_forgejo(Method::DELETE, url)?).await?;
|
||||
} else {
|
||||
Self::send_api_req_empty(
|
||||
self.req_forgejo(Method::PATCH, url)?
|
||||
.json(&CommentBody { body }),
|
||||
)
|
||||
.await?;
|
||||
return Ok(old_comment_id);
|
||||
}
|
||||
}
|
||||
|
||||
let new_c = Self::send_api_req::<IdEntity>(
|
||||
self.req_forgejo(
|
||||
Method::POST,
|
||||
format!(
|
||||
"https://{}/api/v1/repos/{}/{}/issues/{}/comments",
|
||||
query.host, query.user, query.repo, issue_id
|
||||
),
|
||||
)?
|
||||
.json(&CommentBody { body }),
|
||||
)
|
||||
.await?;
|
||||
Ok(new_c.id)
|
||||
}
|
||||
|
||||
async fn add_comment_github(
|
||||
&self,
|
||||
query: QueryRef<'_>,
|
||||
issue_id: u64,
|
||||
body: &str,
|
||||
old_comment_id: Option<u64>,
|
||||
recreate: bool,
|
||||
) -> Result<u64> {
|
||||
if let Some(old_comment_id) = old_comment_id {
|
||||
let url = format!(
|
||||
"https://api.github.com/repos/{}/{}/issues/comments/{}",
|
||||
query.user, query.repo, old_comment_id
|
||||
);
|
||||
if recreate {
|
||||
Self::send_api_req_empty(self.req_github(Method::DELETE, url)?).await?;
|
||||
} else {
|
||||
Self::send_api_req_empty(
|
||||
self.req_github(Method::PATCH, url)?
|
||||
.json(&CommentBody { body }),
|
||||
)
|
||||
.await?;
|
||||
return Ok(old_comment_id);
|
||||
}
|
||||
}
|
||||
|
||||
let new_c = Self::send_api_req::<IdEntity>(
|
||||
self.req_github(
|
||||
Method::POST,
|
||||
format!(
|
||||
"https://api.github.com/repos/{}/{}/issues/{}/comments",
|
||||
query.user, query.repo, issue_id
|
||||
),
|
||||
)?
|
||||
.json(&CommentBody { body }),
|
||||
)
|
||||
.await?;
|
||||
Ok(new_c.id)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "error", skip_all)]
|
||||
pub async fn find_comment(
|
||||
&self,
|
||||
query: QueryRef<'_>,
|
||||
issue_id: u64,
|
||||
) -> Result<Option<Comment>> {
|
||||
let user_id = self.get_user_id(query).await?;
|
||||
if query.is_github() {
|
||||
self.find_comment_github(query, issue_id, user_id).await
|
||||
} else {
|
||||
self.find_comment_forgejo(query, issue_id, user_id).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_comment_forgejo(
|
||||
&self,
|
||||
query: QueryRef<'_>,
|
||||
issue_id: u64,
|
||||
user_id: u64,
|
||||
) -> Result<Option<Comment>> {
|
||||
let comments = Self::send_api_req::<Vec<Comment>>(self.get_forgejo(format!(
|
||||
"https://{}/api/v1/repos/{}/{}/issues/{}/comments",
|
||||
query.host, query.user, query.repo, issue_id
|
||||
)))
|
||||
.await?;
|
||||
|
||||
Ok(comments
|
||||
.into_iter()
|
||||
.find(|c| c.user.id == user_id && c.body.starts_with(COMMENT_TAG_PATTERN)))
|
||||
}
|
||||
|
||||
async fn find_comment_github(
|
||||
&self,
|
||||
query: QueryRef<'_>,
|
||||
issue_id: u64,
|
||||
user_id: u64,
|
||||
) -> Result<Option<Comment>> {
|
||||
for page in 1..=5 {
|
||||
let comments = Self::send_api_req::<Vec<Comment>>(self.get_github(format!(
|
||||
"https://api.github.com/repos/{}/{}/issues/{}/comments?page={}",
|
||||
query.user, query.repo, issue_id, page
|
||||
)))
|
||||
.await?;
|
||||
if let Some(comment) = comments
|
||||
.into_iter()
|
||||
.find(|c| c.user.id == user_id && c.body.starts_with(COMMENT_TAG_PATTERN))
|
||||
{
|
||||
return Ok(Some(comment));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "error", skip_all)]
|
||||
pub async fn get_pr(&self, query: QueryRef<'_>, pr_id: u64) -> Result<PullRequest> {
|
||||
let req = if query.is_github() {
|
||||
self.get_github(format!(
|
||||
"https://api.github.com/repos/{}/{}/pulls/{}",
|
||||
query.user, query.repo, pr_id
|
||||
))
|
||||
} else {
|
||||
self.get_forgejo(format!(
|
||||
"https://{}/api/v1/repos/{}/{}/pulls/{}",
|
||||
query.host, query.user, query.repo, pr_id
|
||||
))
|
||||
};
|
||||
Self::send_api_req(req).await
|
||||
}
|
||||
|
||||
async fn get_user_id(&self, query: QueryRef<'_>) -> Result<u64> {
|
||||
self.user_ids
|
||||
.get_or_insert_async(query.host, async {
|
||||
let user =
|
||||
if query.is_github() {
|
||||
Self::send_api_req::<IdEntity>(
|
||||
self.req_github(Method::GET, "https://api.github.com/user")?,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
Self::send_api_req::<IdEntity>(self.req_forgejo(
|
||||
Method::GET,
|
||||
format!("https://{}/api/v1/user", query.host),
|
||||
)?)
|
||||
.await?
|
||||
};
|
||||
Ok::<_, Error>(user.id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{config::Config, query::ArtifactQuery};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use time::macros::datetime;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
query::{ArtifactQuery, RunQuery},
|
||||
};
|
||||
|
||||
use super::ArtifactApi;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn fetch_forgejo() {
|
||||
let query = ArtifactQuery {
|
||||
host: "code.thetadev.de".to_owned(),
|
||||
host_alias: None,
|
||||
user: "HSA".to_owned(),
|
||||
repo: "Visitenbuch".to_owned(),
|
||||
run: 32,
|
||||
artifact: 1,
|
||||
};
|
||||
let query = ArtifactQuery::from_subdomain(
|
||||
"code-thetadev-de--hsa--visitenbuch--32-1",
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let api = ArtifactApi::new(Config::default());
|
||||
let res = api.fetch(&query).await.unwrap();
|
||||
|
||||
|
@ -303,19 +774,44 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn fetch_github() {
|
||||
let query = ArtifactQuery {
|
||||
host: "github.com".to_owned(),
|
||||
host_alias: None,
|
||||
user: "actions".to_owned(),
|
||||
repo: "upload-artifact".to_owned(),
|
||||
run: 8805345396,
|
||||
artifact: 1440556464,
|
||||
};
|
||||
let query = ArtifactQuery::from_subdomain(
|
||||
"github-com--actions--upload-artifact--8805345396-1440556464",
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let api = ArtifactApi::new(Config::default());
|
||||
let res = api.fetch(&query).await.unwrap();
|
||||
|
||||
assert_eq!(res.id, 1440556464);
|
||||
assert_eq!(res.size, 334);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn workflow_run_forgejo() {
|
||||
let query =
|
||||
RunQuery::from_forge_url("https://codeberg.org/forgejo/forgejo/actions/runs/20471")
|
||||
.unwrap();
|
||||
let api = ArtifactApi::new(Config::default());
|
||||
let res = api.workflow_run(&query).await.unwrap();
|
||||
assert_eq!(res.head_sha, "03581511024aca9b56bc6083565bdcebeacb9d05");
|
||||
assert!(res.from_pr);
|
||||
assert_eq!(res.date_started, Some(datetime!(2024-06-21 9:13:23 UTC)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn workflow_run_github() {
|
||||
let query =
|
||||
RunQuery::from_forge_url("https://github.com/orhun/git-cliff/actions/runs/9588266559")
|
||||
.unwrap();
|
||||
let api = ArtifactApi::new(Config::default());
|
||||
let res = api.workflow_run(&query).await.unwrap();
|
||||
dbg!(&res);
|
||||
assert_eq!(res.head_sha, "0500cb2c5c5ec225e109584236940ee068be2372");
|
||||
assert!(res.from_pr);
|
||||
assert_eq!(res.date_started, Some(datetime!(2024-06-21 9:13:23 UTC)));
|
||||
}
|
||||
}
|
||||
|
|
291
src/cache.rs
|
@ -16,7 +16,7 @@ use mime::Mime;
|
|||
use path_macro::path;
|
||||
use quick_cache::sync::Cache as QuickCache;
|
||||
use serde::Serialize;
|
||||
use serde_hex::{SerHex, Strict};
|
||||
use serde_hex::{SerHex, SerHexOpt};
|
||||
|
||||
use crate::{
|
||||
artifact_api::ArtifactApi,
|
||||
|
@ -54,8 +54,11 @@ pub struct FileEntry {
|
|||
}
|
||||
|
||||
pub struct GetEntryResult {
|
||||
/// Cached zip file metadata
|
||||
pub entry: Arc<CacheEntry>,
|
||||
/// Path to the cached zip file
|
||||
pub zip_path: PathBuf,
|
||||
/// True if the entry was just downloaded
|
||||
pub downloaded: bool,
|
||||
}
|
||||
|
||||
|
@ -65,19 +68,22 @@ pub enum GetFileResult {
|
|||
}
|
||||
|
||||
pub struct GetFileResultFile {
|
||||
pub filename: Option<String>,
|
||||
pub file: FileEntry,
|
||||
pub mime: Option<Mime>,
|
||||
pub status: StatusCode,
|
||||
pub index: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IndexEntry {
|
||||
pub name: String,
|
||||
pub size: u32,
|
||||
#[serde(with = "SerHex::<Strict>")]
|
||||
#[serde(with = "SerHex::<serde_hex::Strict>")]
|
||||
pub crc32: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Listing {
|
||||
pub entries: Vec<ListingEntry>,
|
||||
pub n_files: usize,
|
||||
|
@ -85,16 +91,32 @@ pub struct Listing {
|
|||
pub has_parent: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ListingEntry {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub size: Size,
|
||||
pub crc32: String,
|
||||
pub crc32: Crc32,
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Size(pub u32);
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Crc32(#[serde(with = "SerHexOpt::<serde_hex::Strict>")] pub Option<u32>);
|
||||
|
||||
impl GetFileResult {
|
||||
/// Return true if the result represents a directory index, so the client has to be redirected to the directory path
|
||||
/// if the requested path does not end with a slash (otherwise resources on the index.html may not resolve properly)
|
||||
pub fn index(&self) -> bool {
|
||||
match self {
|
||||
GetFileResult::File(f) => f.index,
|
||||
GetFileResult::Listing(_) => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
pub fn new(cfg: Config) -> Self {
|
||||
Self {
|
||||
|
@ -114,7 +136,7 @@ impl Cache {
|
|||
query: &ArtifactQuery,
|
||||
ip: &IpAddr,
|
||||
) -> Result<GetEntryResult> {
|
||||
let subdomain = query.subdomain_noalias();
|
||||
let subdomain = query.cache_key();
|
||||
let zip_path = path!(self.cfg.load().cache_dir / format!("{subdomain}.zip"));
|
||||
let downloaded = !zip_path.is_file();
|
||||
if downloaded {
|
||||
|
@ -144,10 +166,10 @@ impl Cache {
|
|||
let metadata = tokio::fs::metadata(&zip_path).await?;
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.map_err(|_| Error::Internal("no file modified time".into()))?;
|
||||
.map_err(|_| Error::Other("no file modified time".into()))?;
|
||||
let accessed = metadata
|
||||
.accessed()
|
||||
.map_err(|_| Error::Internal("no file accessed time".into()))?;
|
||||
.map_err(|_| Error::Other("no file accessed time".into()))?;
|
||||
if modified != entry.last_modified {
|
||||
tracing::info!("cached file {zip_path:?} changed");
|
||||
entry = Arc::new(
|
||||
|
@ -160,7 +182,7 @@ impl Cache {
|
|||
let now = SystemTime::now();
|
||||
if now
|
||||
.duration_since(accessed)
|
||||
.map_err(|e| Error::Internal(e.to_string().into()))?
|
||||
.map_err(|e| Error::Other(e.to_string().into()))?
|
||||
> Duration::from_secs(1800)
|
||||
{
|
||||
let file = std::fs::File::open(&zip_path)?;
|
||||
|
@ -193,10 +215,10 @@ impl Cache {
|
|||
.metadata()
|
||||
.await?
|
||||
.accessed()
|
||||
.map_err(|_| Error::Internal("no file accessed time".into()))?;
|
||||
.map_err(|_| Error::Other("no file accessed time".into()))?;
|
||||
if now
|
||||
.duration_since(accessed)
|
||||
.map_err(|e| Error::Internal(e.to_string().into()))?
|
||||
.map_err(|e| Error::Other(e.to_string().into()))?
|
||||
> max_age
|
||||
{
|
||||
let path = entry.path();
|
||||
|
@ -248,8 +270,12 @@ impl CacheEntry {
|
|||
.entries()
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let name = entry.filename().as_str().ok()?;
|
||||
if name.ends_with('/') {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
entry.filename().as_str().ok()?.to_owned(),
|
||||
name.to_owned(),
|
||||
FileEntry {
|
||||
header_offset: entry.header_offset().try_into().ok()?,
|
||||
uncompressed_size: entry.uncompressed_size().try_into().ok()?,
|
||||
|
@ -263,12 +289,12 @@ impl CacheEntry {
|
|||
name,
|
||||
last_modified: meta
|
||||
.modified()
|
||||
.map_err(|_| Error::Internal("no file modified time".into()))?,
|
||||
.map_err(|_| Error::Other("no file modified time".into()))?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_file(&self, path: &str, url_query: &str) -> Result<GetFileResult> {
|
||||
let path = path.trim_start_matches('/');
|
||||
let path = path.trim_matches('/');
|
||||
let mut index_path: Option<Cow<str>> = None;
|
||||
|
||||
if path.is_empty() {
|
||||
|
@ -280,28 +306,45 @@ impl CacheEntry {
|
|||
// 2. Site path + `/index.html`
|
||||
else if let Some(file) = self.files.get(path) {
|
||||
return Ok(GetFileResult::File(GetFileResultFile {
|
||||
filename: path.rsplit('/').next().map(str::to_owned),
|
||||
file: file.clone(),
|
||||
mime: util::path_mime(path),
|
||||
status: StatusCode::OK,
|
||||
index: false,
|
||||
}));
|
||||
} else if util::site_path_ext(path).is_none() {
|
||||
index_path = Some(format!("{path}/index.html").into());
|
||||
}
|
||||
|
||||
if let Some(file) = index_path
|
||||
.and_then(|p: Cow<str>| self.files.get(p.as_ref()))
|
||||
.or_else(|| self.files.get("200.html"))
|
||||
{
|
||||
// index.html or SPA entrypoint
|
||||
if let Some(file) = index_path.and_then(|p: Cow<str>| self.files.get(p.as_ref())) {
|
||||
// index.html
|
||||
return Ok(GetFileResult::File(GetFileResultFile {
|
||||
filename: None,
|
||||
file: file.clone(),
|
||||
mime: Some(mime::TEXT_HTML),
|
||||
status: StatusCode::OK,
|
||||
index: true,
|
||||
}));
|
||||
}
|
||||
|
||||
// Do not show fallback pages for favicon
|
||||
if path == "favicon.ico" {
|
||||
return Err(Error::NotFound("requested file".into()));
|
||||
}
|
||||
|
||||
// SPA entrypoint
|
||||
if let Some(file) = self.files.get("200.html") {
|
||||
return Ok(GetFileResult::File(GetFileResultFile {
|
||||
filename: None,
|
||||
file: file.clone(),
|
||||
mime: Some(mime::TEXT_HTML),
|
||||
status: StatusCode::OK,
|
||||
index: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// Directory listing
|
||||
let path_as_dir: Cow<str> = if path.is_empty() || path.ends_with('/') {
|
||||
let path_as_dir: Cow<str> = if path.is_empty() {
|
||||
path.into()
|
||||
} else {
|
||||
format!("{path}/").into()
|
||||
|
@ -328,9 +371,11 @@ impl CacheEntry {
|
|||
} else if let Some(file) = self.files.get("404.html") {
|
||||
// Custom 404 error page
|
||||
return Ok(GetFileResult::File(GetFileResultFile {
|
||||
filename: None,
|
||||
file: file.clone(),
|
||||
mime: Some(mime::TEXT_HTML),
|
||||
status: StatusCode::NOT_FOUND,
|
||||
index: false,
|
||||
}));
|
||||
}
|
||||
|
||||
|
@ -375,16 +420,16 @@ impl CacheEntry {
|
|||
directories.push(ListingEntry {
|
||||
name: n.to_owned(),
|
||||
url: format!("{n}{path}"),
|
||||
size: Size(0),
|
||||
crc32: "-".to_string(),
|
||||
size: 0.into(),
|
||||
crc32: Crc32(None),
|
||||
is_dir: true,
|
||||
});
|
||||
} else {
|
||||
files.push(ListingEntry {
|
||||
name: n.to_owned(),
|
||||
url: format!("{n}{path}"),
|
||||
size: Size(entry.uncompressed_size),
|
||||
crc32: hex::encode(entry.crc32.to_le_bytes()),
|
||||
size: entry.uncompressed_size.into(),
|
||||
crc32: Crc32(Some(entry.crc32)),
|
||||
is_dir: false,
|
||||
});
|
||||
}
|
||||
|
@ -411,3 +456,205 @@ impl CacheEntry {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u32> for Size {
|
||||
fn from(value: u32) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{net::Ipv4Addr, str::FromStr};
|
||||
|
||||
use rstest::{fixture, rstest};
|
||||
use temp_testdir::TempDir;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct TdCache {
|
||||
cache: Cache,
|
||||
api: ArtifactApi,
|
||||
td: TempDir,
|
||||
}
|
||||
|
||||
impl TdCache {
|
||||
async fn get_entry(&self, subdomain: &str) -> Result<GetEntryResult> {
|
||||
self.cache
|
||||
.get_entry(
|
||||
&self.api,
|
||||
&ArtifactQuery::from_subdomain(subdomain, &HashMap::new()).unwrap(),
|
||||
&IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn cache() -> TdCache {
|
||||
let td = TempDir::default();
|
||||
util::tests::setup_cache_dir(&td);
|
||||
let cfg = Config::from_data(crate::ConfigData {
|
||||
cache_dir: td.to_path_buf(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let cache = Cache::new(cfg.clone());
|
||||
let api = ArtifactApi::new(cfg);
|
||||
TdCache { cache, api, td }
|
||||
}
|
||||
|
||||
const S1: &str = "codeberg-org--thetadev--artifactview-test--1-1";
|
||||
const Z1: &str = "codeberg-org--thetadev--artifactview-test--1-1.zip";
|
||||
const S2: &str = "codeberg-org--thetadev--artifactview-test--1-2";
|
||||
const Z2: &str = "codeberg-org--thetadev--artifactview-test--1-2.zip";
|
||||
const S3: &str = "codeberg-org--thetadev--artifactview-test--1-3";
|
||||
const Z3: &str = "codeberg-org--thetadev--artifactview-test--1-3.zip";
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn get_entry(cache: TdCache) {
|
||||
let entry = cache.get_entry(S1).await.unwrap();
|
||||
|
||||
assert_eq!(entry.entry.name, "view");
|
||||
assert_eq!(entry.zip_path, path!(cache.td / Z1));
|
||||
assert!(!entry.downloaded);
|
||||
|
||||
let files = entry.entry.get_files();
|
||||
let mut filenames = files.iter().map(|f| f.name.as_str()).collect::<Vec<_>>();
|
||||
filenames.sort();
|
||||
assert_eq!(
|
||||
filenames,
|
||||
[
|
||||
".well-known/test.txt",
|
||||
"README.md",
|
||||
"example.rs",
|
||||
"junit/hello.junit.xml",
|
||||
"junit/retry.junit.xml",
|
||||
"junit/simple.junit.xml",
|
||||
"robots.txt",
|
||||
"sites/index.html",
|
||||
"sites/style.css"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn garbage_collect(cache: TdCache) {
|
||||
let ago = SystemTime::now() - Duration::from_secs(13 * 3600);
|
||||
let file = std::fs::File::open(path!(cache.td / Z1)).unwrap();
|
||||
file.set_times(FileTimes::new().set_accessed(ago)).unwrap();
|
||||
let file = std::fs::File::open(path!(cache.td / Z2)).unwrap();
|
||||
file.set_times(FileTimes::new().set_accessed(ago)).unwrap();
|
||||
|
||||
// Access artifact 1, artifact 2 should be deleted
|
||||
cache.get_entry(S1).await.unwrap();
|
||||
|
||||
cache.cache.garbage_collect().await.unwrap();
|
||||
|
||||
assert!(path!(cache.td / Z1).is_file());
|
||||
assert!(path!(cache.td / format!("{S1}.name")).is_file());
|
||||
assert!(path!(cache.td / Z3).is_file());
|
||||
assert!(path!(cache.td / format!("{S3}.name")).is_file());
|
||||
assert!(!path!(cache.td / Z2).is_file());
|
||||
assert!(!path!(cache.td / format!("{S2}.name")).is_file());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn get_file(cache: TdCache) {
|
||||
let entry = cache.get_entry(S1).await.unwrap();
|
||||
let res = entry.entry.get_file("example.rs", "").unwrap();
|
||||
if let GetFileResult::File(file) = res {
|
||||
assert_eq!(file.filename, Some("example.rs".to_string()));
|
||||
assert_eq!(file.file.crc32, 0x2013120c);
|
||||
assert_eq!(file.status, StatusCode::OK);
|
||||
assert_eq!(file.mime, Some(Mime::from_str("text/x-rust").unwrap()));
|
||||
} else {
|
||||
panic!("no file")
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn get_file_spa(cache: TdCache) {
|
||||
let entry = cache.get_entry(S3).await.unwrap();
|
||||
let res = entry.entry.get_file("foo/bar", "").unwrap();
|
||||
if let GetFileResult::File(file) = res {
|
||||
assert_eq!(file.filename, None);
|
||||
assert_eq!(file.file.crc32, 0xBE336584);
|
||||
assert_eq!(file.status, StatusCode::OK);
|
||||
assert_eq!(file.mime, Some(Mime::from_str("text/html").unwrap()));
|
||||
} else {
|
||||
panic!("no file")
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn get_file_404(cache: TdCache) {
|
||||
let entry = cache.get_entry(S2).await.unwrap();
|
||||
let res = entry.entry.get_file("foo/bar", "").unwrap();
|
||||
if let GetFileResult::File(file) = res {
|
||||
assert_eq!(file.filename, None);
|
||||
assert_eq!(file.file.crc32, 0x69F73F18);
|
||||
assert_eq!(file.status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(file.mime, Some(Mime::from_str("text/html").unwrap()));
|
||||
} else {
|
||||
panic!("no file")
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("", &[
|
||||
".well-known/",
|
||||
"junit/",
|
||||
"sites/",
|
||||
"README.md",
|
||||
"example.rs",
|
||||
"robots.txt"
|
||||
])]
|
||||
#[case("C=N&O=D", &[
|
||||
"sites/",
|
||||
"junit/",
|
||||
".well-known/",
|
||||
"robots.txt",
|
||||
"example.rs",
|
||||
"README.md",
|
||||
])]
|
||||
#[case("C=S&O=A", &[
|
||||
".well-known/",
|
||||
"junit/",
|
||||
"sites/",
|
||||
"robots.txt",
|
||||
"example.rs",
|
||||
"README.md",
|
||||
])]
|
||||
#[case("C=S&O=D", &[
|
||||
".well-known/",
|
||||
"junit/",
|
||||
"sites/",
|
||||
"README.md",
|
||||
"example.rs",
|
||||
"robots.txt"
|
||||
])]
|
||||
#[tokio::test]
|
||||
async fn get_file_listing(cache: TdCache, #[case] query: &str, #[case] expect: &[&str]) {
|
||||
let entry = cache.get_entry(S1).await.unwrap();
|
||||
let res = entry.entry.get_file("", query).unwrap();
|
||||
if let GetFileResult::Listing(listing) = res {
|
||||
let filenames = listing
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| e.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(filenames, expect);
|
||||
assert_eq!(listing.n_dirs, 3);
|
||||
assert_eq!(listing.n_files, 3);
|
||||
assert!(!listing.has_parent);
|
||||
} else {
|
||||
panic!("no listing")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ use std::{
|
|||
sync::Arc,
|
||||
};
|
||||
|
||||
use secrecy::SecretString;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
|
@ -27,6 +28,8 @@ struct ConfigInner {
|
|||
pub struct ConfigData {
|
||||
/// Folder where the downloaded artifacts are stored
|
||||
pub cache_dir: PathBuf,
|
||||
/// Port number of the web server
|
||||
pub port: u16,
|
||||
/// Root domain under which the server is available
|
||||
///
|
||||
/// The individual artifacts are served under `<subdomain>.<root_domain>`
|
||||
|
@ -46,7 +49,9 @@ pub struct ConfigData {
|
|||
/// GitHub API token for downloading GitHub artifacts
|
||||
///
|
||||
/// Using a fine-grained token with public read permissions is recommended.
|
||||
pub github_token: Option<String>,
|
||||
pub github_token: Option<SecretString>,
|
||||
/// Forgejo/Gitea API tokens by host
|
||||
pub forgejo_tokens: HashMap<String, SecretString>,
|
||||
/// Number of artifact indexes to keep in memory
|
||||
pub mem_cache_size: usize,
|
||||
/// Get the client IP address from a HTTP request header
|
||||
|
@ -59,18 +64,28 @@ pub struct ConfigData {
|
|||
pub real_ip_header: Option<String>,
|
||||
/// Limit the amount of downloaded artifacts per IP address and minute
|
||||
pub limit_artifacts_per_min: Option<NonZeroU32>,
|
||||
/// Limit the amount of PR comment API requests per IP address and minute
|
||||
pub limit_pr_comments_per_min: Option<NonZeroU32>,
|
||||
/// List of sites/users/repos that can NOT be accessed
|
||||
pub repo_blacklist: QueryFilterList,
|
||||
/// List of sites/users/repos that can ONLY be accessed
|
||||
pub repo_whitelist: QueryFilterList,
|
||||
/// List of suggested code forges (host only, without https://)
|
||||
///
|
||||
/// If repo_whitelist is empty, this value is used for the matched sites in the userscript
|
||||
/// as well as the url placeholder on the home page
|
||||
pub suggested_sites: Vec<String>,
|
||||
/// Aliases for sites (Example: `gh => github.com`)
|
||||
pub site_aliases: HashMap<String, String>,
|
||||
/// Maximum file size for the viewer
|
||||
pub viewer_max_size: Option<NonZeroU32>,
|
||||
}
|
||||
|
||||
impl Default for ConfigData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cache_dir: Path::new("/tmp/artifactview").into(),
|
||||
port: 3000,
|
||||
root_domain: "localhost:3000".to_string(),
|
||||
no_https: false,
|
||||
max_artifact_size: Some(NonZeroU32::new(100_000_000).unwrap()),
|
||||
|
@ -79,12 +94,20 @@ impl Default for ConfigData {
|
|||
max_age_h: NonZeroU32::new(12).unwrap(),
|
||||
zip_timeout_ms: Some(NonZeroU32::new(1000).unwrap()),
|
||||
github_token: None,
|
||||
forgejo_tokens: HashMap::new(),
|
||||
mem_cache_size: 50,
|
||||
real_ip_header: None,
|
||||
limit_artifacts_per_min: Some(NonZeroU32::new(5).unwrap()),
|
||||
limit_pr_comments_per_min: Some(NonZeroU32::new(5).unwrap()),
|
||||
repo_blacklist: QueryFilterList::default(),
|
||||
repo_whitelist: QueryFilterList::default(),
|
||||
suggested_sites: vec![
|
||||
String::from("codeberg.org"),
|
||||
String::from("github.com"),
|
||||
String::from("gitea.com"),
|
||||
],
|
||||
site_aliases: HashMap::new(),
|
||||
viewer_max_size: Some(NonZeroU32::new(500_000).unwrap()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -108,7 +131,7 @@ impl ConfigData {
|
|||
impl Config {
|
||||
pub fn new() -> Result<Self> {
|
||||
let data =
|
||||
envy::from_env::<ConfigData>().map_err(|e| Error::Internal(e.to_string().into()))?;
|
||||
envy::from_env::<ConfigData>().map_err(|e| Error::Other(e.to_string().into()))?;
|
||||
Self::from_data(data)
|
||||
}
|
||||
|
||||
|
@ -148,7 +171,16 @@ impl Config {
|
|||
&self.i.main_url
|
||||
}
|
||||
|
||||
pub fn check_filterlist(&self, query: &Query) -> Result<()> {
|
||||
pub fn example_site(&self) -> &str {
|
||||
self.i
|
||||
.data
|
||||
.repo_whitelist
|
||||
.first_host()
|
||||
.or_else(|| self.i.data.suggested_sites.first().map(|s| s.as_str()))
|
||||
.unwrap_or("codeberg.org")
|
||||
}
|
||||
|
||||
pub fn check_filterlist<Q: Query>(&self, query: &Q) -> Result<()> {
|
||||
if !self.i.data.repo_blacklist.passes(query, true) {
|
||||
Err(Error::Forbidden("repository is blacklisted".into()))
|
||||
} else if !self.i.data.repo_whitelist.passes(query, false) {
|
||||
|
|
14
src/error.rs
|
@ -20,8 +20,8 @@ pub enum Error {
|
|||
Io(#[from] std::io::Error),
|
||||
#[error("Zip: {0}")]
|
||||
Zip(#[from] async_zip::error::ZipError),
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(Cow<'static, str>),
|
||||
#[error("Error: {0}")]
|
||||
Other(Cow<'static, str>),
|
||||
|
||||
#[error("Invalid request: {0}")]
|
||||
BadRequest(Cow<'static, str>),
|
||||
|
@ -35,12 +35,16 @@ pub enum Error {
|
|||
Inaccessible,
|
||||
#[error("This artifact has already expired")]
|
||||
Expired,
|
||||
#[error("timeout")]
|
||||
#[error("This action took too long")]
|
||||
Timeout(#[from] tokio::time::error::Elapsed),
|
||||
#[error("Method not allowed")]
|
||||
MethodNotAllowed,
|
||||
#[error("You are fetching new artifacts too fast, please wait a minute and try again")]
|
||||
Ratelimit,
|
||||
#[error("viewer: {0}")]
|
||||
Viewer(Cow<'static, str>),
|
||||
#[error("viewer not applicable")]
|
||||
ViewerNotApplicable,
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for Error {
|
||||
|
@ -54,13 +58,13 @@ impl From<reqwest::Error> for Error {
|
|||
|
||||
impl From<std::num::TryFromIntError> for Error {
|
||||
fn from(value: std::num::TryFromIntError) -> Self {
|
||||
Self::Internal(value.to_string().into())
|
||||
Self::Other(value.to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<url::ParseError> for Error {
|
||||
fn from(value: url::ParseError) -> Self {
|
||||
Self::Internal(value.to_string().into())
|
||||
Self::Other(value.to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,5 +7,8 @@ mod gzip_reader;
|
|||
mod query;
|
||||
mod templates;
|
||||
mod util;
|
||||
mod viewer;
|
||||
|
||||
pub struct App;
|
||||
pub use app::{App, AppState};
|
||||
pub use config::{Config, ConfigData};
|
||||
pub use error::Error;
|
||||
|
|
337
src/query.rs
|
@ -1,4 +1,7 @@
|
|||
use std::{collections::HashMap, fmt::Write, str::FromStr};
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap},
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::{Captures, Regex};
|
||||
|
@ -6,53 +9,115 @@ use serde::{de::Visitor, Deserialize};
|
|||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
templates::LinkItem,
|
||||
util,
|
||||
};
|
||||
|
||||
/// Query to select an artifact
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Query {
|
||||
Artifact(ArtifactQuery),
|
||||
Run(RunQuery),
|
||||
}
|
||||
|
||||
pub type RunQuery = QueryData<()>;
|
||||
pub type ArtifactQuery = QueryData<u64>;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct QueryData<T> {
|
||||
pub struct ArtifactQuery {
|
||||
/// Forge host
|
||||
pub host: String,
|
||||
/// Host alias if the query was constructed using one
|
||||
pub host_alias: Option<String>,
|
||||
host_alias: Option<String>,
|
||||
/// User/org name (case-insensitive)
|
||||
pub user: String,
|
||||
/// Repository name (case-insensitive)
|
||||
pub repo: String,
|
||||
/// CI run id
|
||||
pub run: u64,
|
||||
// Optional selected artifact
|
||||
pub artifact: T,
|
||||
/// CI artifact id
|
||||
pub artifact: u64,
|
||||
}
|
||||
|
||||
/// Query to select a CI run (set of artifacts)
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct RunQuery {
|
||||
/// Forge host
|
||||
pub host: String,
|
||||
/// Host alias if the query was constructed using one
|
||||
host_alias: Option<String>,
|
||||
/// User/org name (case-insensitive)
|
||||
pub user: String,
|
||||
/// Repository name (case-insensitive)
|
||||
pub repo: String,
|
||||
/// CI run id
|
||||
pub run: u64,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct QueryRef<'a> {
|
||||
/// Forge host
|
||||
pub host: &'a str,
|
||||
/// Host alias if the query was constructed using one
|
||||
host_alias: Option<&'a str>,
|
||||
/// User/org name (case-insensitive)
|
||||
pub user: &'a str,
|
||||
/// Repository name (case-insensitive)
|
||||
pub repo: &'a str,
|
||||
/// CI run id
|
||||
pub run: u64,
|
||||
}
|
||||
|
||||
pub trait Query {
|
||||
fn as_ref(&self) -> QueryRef<'_>;
|
||||
|
||||
fn shortid(&self) -> String {
|
||||
let q = self.as_ref();
|
||||
format!("{}/{}#{}", q.user, q.repo, q.run)
|
||||
}
|
||||
|
||||
fn forge_url(&self) -> String {
|
||||
let q = self.as_ref();
|
||||
format!(
|
||||
"https://{}/{}/{}/actions/runs/{}",
|
||||
q.host, q.user, q.repo, q.run
|
||||
)
|
||||
}
|
||||
|
||||
fn is_github(&self) -> bool {
|
||||
self.as_ref().host == "github.com"
|
||||
}
|
||||
|
||||
fn subdomain_with_artifact(&self, artifact: u64) -> String {
|
||||
let q = self.as_ref();
|
||||
let host = q.host_alias.unwrap_or(q.host);
|
||||
|
||||
format!(
|
||||
"{}--{}--{}--{}-{}",
|
||||
encode_domain(host, '.'),
|
||||
encode_domain(q.user, '-'),
|
||||
encode_domain(q.repo, '-'),
|
||||
q.run,
|
||||
artifact,
|
||||
)
|
||||
}
|
||||
|
||||
fn publisher(&self) -> LinkItem {
|
||||
let q = self.as_ref();
|
||||
LinkItem {
|
||||
name: q.user.to_owned(),
|
||||
url: format!("https://{}/{}", q.host, q.user),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static RE_REPO_NAME: Lazy<Regex> = Lazy::new(|| Regex::new("^[a-z0-9\\-_\\.]+$").unwrap());
|
||||
|
||||
impl Query {
|
||||
impl ArtifactQuery {
|
||||
pub fn from_subdomain(subdomain: &str, aliases: &HashMap<String, String>) -> Result<Self> {
|
||||
let segments = subdomain.split("--").collect::<Vec<_>>();
|
||||
if segments.len() != 4 {
|
||||
return Err(Error::InvalidUrl);
|
||||
}
|
||||
|
||||
let run_and_artifact = segments[3].split('-').collect::<Vec<_>>();
|
||||
if run_and_artifact.is_empty() || run_and_artifact.len() > 2 {
|
||||
return Err(Error::InvalidUrl);
|
||||
}
|
||||
|
||||
let mut host = decode_domain(segments[0], '.');
|
||||
let mut host_alias = None;
|
||||
let user = decode_domain(segments[1], '-');
|
||||
let repo = decode_domain(segments[2], '-');
|
||||
let run = run_and_artifact[0].parse().ok().ok_or(Error::InvalidUrl)?;
|
||||
let run_and_artifact = segments[3].split_once('-').ok_or(Error::InvalidUrl)?;
|
||||
let run = run_and_artifact.0.parse().ok().ok_or(Error::InvalidUrl)?;
|
||||
let artifact = run_and_artifact.1.parse().ok().ok_or(Error::InvalidUrl)?;
|
||||
|
||||
#[allow(clippy::assigning_clones)]
|
||||
if let Some(alias) = aliases.get(&host) {
|
||||
|
@ -60,27 +125,34 @@ impl Query {
|
|||
host = alias.clone();
|
||||
}
|
||||
|
||||
Ok(match run_and_artifact.get(1) {
|
||||
Some(x) => Self::Artifact(QueryData {
|
||||
Ok(ArtifactQuery {
|
||||
host,
|
||||
host_alias,
|
||||
user,
|
||||
repo,
|
||||
run,
|
||||
artifact: x.parse().ok().ok_or(Error::InvalidUrl)?,
|
||||
}),
|
||||
None => Self::Run(QueryData {
|
||||
host,
|
||||
host_alias,
|
||||
user,
|
||||
repo,
|
||||
run,
|
||||
artifact: (),
|
||||
}),
|
||||
artifact,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_forge_url(url: &str, aliases: &HashMap<String, String>) -> Result<Self> {
|
||||
pub fn cache_key(&self) -> String {
|
||||
format!(
|
||||
"{}--{}--{}--{}-{}",
|
||||
encode_domain(&self.host, '.'),
|
||||
encode_domain(&self.user, '-'),
|
||||
encode_domain(&self.repo, '-'),
|
||||
self.run,
|
||||
self.artifact,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl RunQuery {
|
||||
pub fn from_forge_url(url: &str) -> Result<Self> {
|
||||
Self::from_forge_url_alias(url, &HashMap::new())
|
||||
}
|
||||
|
||||
pub fn from_forge_url_alias(url: &str, aliases: &HashMap<String, String>) -> Result<Self> {
|
||||
let (host, mut path_segs) = util::parse_url(url)?;
|
||||
|
||||
let user = path_segs
|
||||
|
@ -92,8 +164,8 @@ impl Query {
|
|||
.ok_or(Error::BadRequest("no repository".into()))?
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if !path_segs.next().is_some_and(|s| s == "actions")
|
||||
|| !path_segs.next().is_some_and(|s| s == "runs")
|
||||
if path_segs.next().is_none_or(|s| s != "actions")
|
||||
|| path_segs.next().is_none_or(|s| s != "runs")
|
||||
{
|
||||
return Err(Error::BadRequest("invalid Actions URL".into()));
|
||||
}
|
||||
|
@ -104,118 +176,74 @@ impl Query {
|
|||
return Err(Error::BadRequest("invalid repository name".into()));
|
||||
}
|
||||
|
||||
let host = aliases
|
||||
let host_alias = aliases
|
||||
.iter()
|
||||
.find(|(_, v)| *v == host)
|
||||
.map(|(k, _)| k.to_owned())
|
||||
.unwrap_or_else(|| host.to_owned());
|
||||
.map(|(k, _)| k.to_owned());
|
||||
|
||||
let run = path_segs
|
||||
.next()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.ok_or(Error::BadRequest("no run ID".into()))?;
|
||||
|
||||
Ok(Self::Run(RunQuery {
|
||||
host,
|
||||
host_alias: None,
|
||||
Ok(Self {
|
||||
host: host.to_owned(),
|
||||
host_alias,
|
||||
user,
|
||||
repo,
|
||||
run,
|
||||
artifact: (),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subdomain(&self) -> Result<String> {
|
||||
match self {
|
||||
Query::Artifact(q) => q.subdomain(),
|
||||
Query::Run(q) => q.subdomain(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_runquery(self) -> RunQuery {
|
||||
match self {
|
||||
Query::Artifact(q) => q.into_runquery(),
|
||||
Query::Run(q) => q,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_into_artifactquery(self) -> Result<ArtifactQuery> {
|
||||
match self {
|
||||
Query::Artifact(q) => Ok(q),
|
||||
Query::Run(_) => Err(Error::BadRequest("no artifact specified".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtifactQuery {
|
||||
pub fn subdomain(&self) -> Result<String> {
|
||||
self.subdomain_with_artifact(Some(self.artifact))
|
||||
}
|
||||
|
||||
/// Non-shortened subdomain (used for cache storage)
|
||||
pub fn subdomain_noalias(&self) -> String {
|
||||
self._subdomain(Some(self.artifact), false)
|
||||
}
|
||||
}
|
||||
|
||||
impl RunQuery {
|
||||
pub fn subdomain(&self) -> Result<String> {
|
||||
self.subdomain_with_artifact(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> QueryData<T> {
|
||||
pub fn _subdomain(&self, artifact: Option<u64>, use_alias: bool) -> String {
|
||||
let host = if use_alias {
|
||||
self.host_alias.as_deref().unwrap_or(&self.host)
|
||||
} else {
|
||||
&self.host
|
||||
};
|
||||
|
||||
let mut res = format!(
|
||||
pub fn cache_key(&self) -> String {
|
||||
format!(
|
||||
"{}--{}--{}--{}",
|
||||
encode_domain(host, '.'),
|
||||
encode_domain(&self.host, '.'),
|
||||
encode_domain(&self.user, '-'),
|
||||
encode_domain(&self.repo, '-'),
|
||||
self.run,
|
||||
);
|
||||
if let Some(artifact) = artifact {
|
||||
write!(res, "-{artifact}").unwrap();
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
pub fn subdomain_with_artifact(&self, artifact: Option<u64>) -> Result<String> {
|
||||
let res = self._subdomain(artifact, true);
|
||||
if res.len() > 63 {
|
||||
return Err(Error::BadRequest("subdomain too long".into()));
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub fn shortid(&self) -> String {
|
||||
format!("{}/{}#{}", self.user, self.repo, self.run)
|
||||
}
|
||||
|
||||
pub fn forge_url(&self) -> String {
|
||||
format!(
|
||||
"https://{}/{}/{}/actions/runs/{}",
|
||||
self.host, self.user, self.repo, self.run
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_github(&self) -> bool {
|
||||
self.host == "github.com"
|
||||
}
|
||||
|
||||
pub fn into_runquery(self) -> RunQuery {
|
||||
RunQuery {
|
||||
host: self.host,
|
||||
host_alias: self.host_alias,
|
||||
user: self.user,
|
||||
repo: self.repo,
|
||||
impl Query for ArtifactQuery {
|
||||
fn as_ref(&self) -> QueryRef<'_> {
|
||||
QueryRef {
|
||||
host: &self.host,
|
||||
host_alias: self.host_alias.as_deref(),
|
||||
user: &self.user,
|
||||
repo: &self.repo,
|
||||
run: self.run,
|
||||
artifact: (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Query for RunQuery {
|
||||
fn as_ref(&self) -> QueryRef<'_> {
|
||||
QueryRef {
|
||||
host: &self.host,
|
||||
host_alias: self.host_alias.as_deref(),
|
||||
user: &self.user,
|
||||
repo: &self.repo,
|
||||
run: self.run,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Query for QueryRef<'_> {
|
||||
fn as_ref(&self) -> QueryRef<'_> {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ArtifactQuery> for RunQuery {
|
||||
fn from(value: ArtifactQuery) -> Self {
|
||||
Self {
|
||||
host: value.host,
|
||||
host_alias: value.host_alias,
|
||||
user: value.user,
|
||||
repo: value.repo,
|
||||
run: value.run,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -223,11 +251,11 @@ impl<T> QueryData<T> {
|
|||
fn encode_domain(s: &str, bias: char) -> String {
|
||||
// Check if the character at the given position is in the middle of the string
|
||||
// and it is not followed by escape seq numbers or further escapable characters
|
||||
let is_mid_single = |pos: usize| -> bool {
|
||||
if pos == 0 || pos >= (s.len() - 1) {
|
||||
let is_mid_single = |str: &str, pos: usize| -> bool {
|
||||
if pos == 0 || pos >= (str.len() - 1) {
|
||||
return false;
|
||||
}
|
||||
let next_char = s[pos..].chars().nth(1).unwrap();
|
||||
let next_char = str[pos..].chars().nth(1).unwrap();
|
||||
!('0'..='2').contains(&next_char) && !matches!(next_char, '-' | '.' | '_')
|
||||
};
|
||||
|
||||
|
@ -236,7 +264,7 @@ fn encode_domain(s: &str, bias: char) -> String {
|
|||
let mut last_pos = 0;
|
||||
for (pos, c) in s.match_indices('-') {
|
||||
buf += &s[last_pos..pos];
|
||||
if bias == '-' && is_mid_single(pos) {
|
||||
if bias == '-' && is_mid_single(s, pos) {
|
||||
buf.push('-');
|
||||
} else {
|
||||
buf += "-1";
|
||||
|
@ -251,7 +279,7 @@ fn encode_domain(s: &str, bias: char) -> String {
|
|||
for (pos, c) in buf.match_indices(['.', '_']) {
|
||||
buf2 += &buf[last_pos..pos];
|
||||
let cchar = c.chars().next().unwrap();
|
||||
if cchar == bias && is_mid_single(pos) {
|
||||
if cchar == bias && is_mid_single(&buf, pos) {
|
||||
buf2.push('-');
|
||||
} else if cchar == '.' {
|
||||
buf2 += "-0"
|
||||
|
@ -307,12 +335,12 @@ impl FromStr for QueryFilter {
|
|||
|
||||
if let Some(user) = &user {
|
||||
if !RE_REPO_NAME.is_match(user) {
|
||||
return Err(Error::Internal("invalid username".into()));
|
||||
return Err(Error::Other("invalid username".into()));
|
||||
}
|
||||
}
|
||||
if let Some(repo) = &repo {
|
||||
if !RE_REPO_NAME.is_match(repo) {
|
||||
return Err(Error::Internal("invalid repository name".into()));
|
||||
return Err(Error::Other("invalid repository name".into()));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -325,14 +353,11 @@ impl FromStr for QueryFilter {
|
|||
}
|
||||
|
||||
impl QueryFilter {
|
||||
pub fn passes(&self, query: &Query) -> bool {
|
||||
let (host, user, repo) = match query {
|
||||
Query::Artifact(q) => (&q.host, &q.user, &q.repo),
|
||||
Query::Run(q) => (&q.host, &q.user, &q.repo),
|
||||
};
|
||||
&self.host == host
|
||||
&& self.user.as_deref().map(|u| u == user).unwrap_or(true)
|
||||
&& self.repo.as_deref().map(|r| r == repo).unwrap_or(true)
|
||||
pub fn passes<Q: Query>(&self, query: &Q) -> bool {
|
||||
let q = query.as_ref();
|
||||
self.host == q.host
|
||||
&& self.user.as_deref().map(|u| u == q.user).unwrap_or(true)
|
||||
&& self.repo.as_deref().map(|r| r == q.repo).unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -349,13 +374,25 @@ impl FromStr for QueryFilterList {
|
|||
}
|
||||
|
||||
impl QueryFilterList {
|
||||
pub fn passes(&self, query: &Query, blacklist: bool) -> bool {
|
||||
pub fn passes<Q: Query>(&self, query: &Q, blacklist: bool) -> bool {
|
||||
if self.0.is_empty() {
|
||||
true
|
||||
} else {
|
||||
self.0.iter().any(|itm| itm.passes(query)) ^ blacklist
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
pub fn first_host(&self) -> Option<&str> {
|
||||
self.0.first().map(|f| f.host.as_str())
|
||||
}
|
||||
|
||||
pub fn hosts(&self) -> BTreeSet<&str> {
|
||||
self.0.iter().map(|f| f.host.as_str()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for QueryFilterList {
|
||||
|
@ -365,7 +402,7 @@ impl<'de> Deserialize<'de> for QueryFilterList {
|
|||
{
|
||||
struct QueryFilterListVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for QueryFilterListVisitor {
|
||||
impl Visitor<'_> for QueryFilterListVisitor {
|
||||
type Value = QueryFilterList;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
|
@ -388,9 +425,9 @@ impl<'de> Deserialize<'de> for QueryFilterList {
|
|||
mod tests {
|
||||
use std::{collections::HashMap, str::FromStr};
|
||||
|
||||
use crate::query::{QueryFilter, QueryFilterList};
|
||||
use crate::query::{Query, QueryFilter, QueryFilterList};
|
||||
|
||||
use super::{ArtifactQuery, Query};
|
||||
use super::ArtifactQuery;
|
||||
|
||||
use proptest::prelude::*;
|
||||
use rstest::rstest;
|
||||
|
@ -426,19 +463,19 @@ mod tests {
|
|||
#[test]
|
||||
fn query_from_subdomain() {
|
||||
let d1 = "github-com--thetadev--newpipe-extractor--14-123";
|
||||
let query = Query::from_subdomain(d1, &HashMap::new()).unwrap();
|
||||
let query = ArtifactQuery::from_subdomain(d1, &HashMap::new()).unwrap();
|
||||
assert_eq!(
|
||||
query,
|
||||
Query::Artifact(ArtifactQuery {
|
||||
ArtifactQuery {
|
||||
host: "github.com".to_owned(),
|
||||
host_alias: None,
|
||||
user: "thetadev".to_owned(),
|
||||
repo: "newpipe-extractor".to_owned(),
|
||||
run: 14,
|
||||
artifact: 123
|
||||
})
|
||||
}
|
||||
);
|
||||
assert_eq!(query.subdomain().unwrap(), d1);
|
||||
assert_eq!(query.subdomain_with_artifact(query.artifact), d1);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
|
|
11
src/snapshots/artifactview__app__tests__pr_comment_1.snap
Normal file
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
source: src/app.rs
|
||||
expression: res
|
||||
---
|
||||
### <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/1" target="_blank" rel="noopener noreferrer">Latest build artifacts</a>
|
||||
|
||||
Run [#1](https://code.thetadev.de/thetadev/test-actions/actions/runs/1) · [15eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/15eed48a8382513147a949117ef4aa659989d397) · 15.06.2024 01:30:00 UTC
|
||||
|
||||
🏠 <a href="https://code-thetadev-de--thetadev--test-actions--1-1.localhost:3000" target="_blank" rel="noopener noreferrer">Hello World ;-)</a><br>
|
||||
<a href="https://code-thetadev-de--thetadev--test-actions--1-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">Test</a><br>
|
||||
<!--NEXT_PREV - [#1](https://code.thetadev.de/thetadev/test-actions/actions/runs/1) [[15eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/15eed48a8382513147a949117ef4aa659989d397)] <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/1" target="_blank" rel="noopener noreferrer">Artifacts</a>: <a href="https://code-thetadev-de--thetadev--test-actions--1-1.localhost:3000" target="_blank" rel="noopener noreferrer">`Hello`</a>, <a href="https://code-thetadev-de--thetadev--test-actions--1-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">`Test`</a> (15.06.2024 01:30:00 UTC) -->
|
16
src/snapshots/artifactview__app__tests__pr_comment_2.snap
Normal file
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
source: src/app.rs
|
||||
expression: res
|
||||
---
|
||||
### <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/2" target="_blank" rel="noopener noreferrer">Latest build artifacts</a>
|
||||
|
||||
Run [#2](https://code.thetadev.de/thetadev/test-actions/actions/runs/2) · [25eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/25eed48a8382513147a949117ef4aa659989d397) · 15.06.2024 02:30:00 UTC
|
||||
|
||||
🏠 <a href="https://code-thetadev-de--thetadev--test-actions--2-1.localhost:3000" target="_blank" rel="noopener noreferrer">Hello World ;-)</a><br>
|
||||
<a href="https://code-thetadev-de--thetadev--test-actions--2-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">Test</a><br>
|
||||
<details>
|
||||
<summary>Previous builds</summary>
|
||||
|
||||
- [#1](https://code.thetadev.de/thetadev/test-actions/actions/runs/1) [[15eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/15eed48a8382513147a949117ef4aa659989d397)] <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/1" target="_blank" rel="noopener noreferrer">Artifacts</a>: <a href="https://code-thetadev-de--thetadev--test-actions--1-1.localhost:3000" target="_blank" rel="noopener noreferrer">`Hello`</a>, <a href="https://code-thetadev-de--thetadev--test-actions--1-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">`Test`</a> (15.06.2024 01:30:00 UTC)
|
||||
<!--NEXT_PREV - [#2](https://code.thetadev.de/thetadev/test-actions/actions/runs/2) [[25eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/25eed48a8382513147a949117ef4aa659989d397)] <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/2" target="_blank" rel="noopener noreferrer">Artifacts</a>: <a href="https://code-thetadev-de--thetadev--test-actions--2-1.localhost:3000" target="_blank" rel="noopener noreferrer">`Hello`</a>, <a href="https://code-thetadev-de--thetadev--test-actions--2-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">`Test`</a> (15.06.2024 02:30:00 UTC) -->
|
||||
</details>
|
17
src/snapshots/artifactview__app__tests__pr_comment_3.snap
Normal file
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
source: src/app.rs
|
||||
expression: res
|
||||
---
|
||||
### <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/3" target="_blank" rel="noopener noreferrer">Latest build artifacts</a>
|
||||
|
||||
Run [#3](https://code.thetadev.de/thetadev/test-actions/actions/runs/3) · [35eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/35eed48a8382513147a949117ef4aa659989d397) · 15.06.2024 03:30:00 UTC
|
||||
|
||||
🏠 <a href="https://code-thetadev-de--thetadev--test-actions--3-1.localhost:3000" target="_blank" rel="noopener noreferrer">Hello World ;-)</a><br>
|
||||
<a href="https://code-thetadev-de--thetadev--test-actions--3-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">Test</a><br>
|
||||
<details>
|
||||
<summary>Previous builds</summary>
|
||||
|
||||
- [#1](https://code.thetadev.de/thetadev/test-actions/actions/runs/1) [[15eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/15eed48a8382513147a949117ef4aa659989d397)] <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/1" target="_blank" rel="noopener noreferrer">Artifacts</a>: <a href="https://code-thetadev-de--thetadev--test-actions--1-1.localhost:3000" target="_blank" rel="noopener noreferrer">`Hello`</a>, <a href="https://code-thetadev-de--thetadev--test-actions--1-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">`Test`</a> (15.06.2024 01:30:00 UTC)
|
||||
- [#2](https://code.thetadev.de/thetadev/test-actions/actions/runs/2) [[25eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/25eed48a8382513147a949117ef4aa659989d397)] <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/2" target="_blank" rel="noopener noreferrer">Artifacts</a>: <a href="https://code-thetadev-de--thetadev--test-actions--2-1.localhost:3000" target="_blank" rel="noopener noreferrer">`Hello`</a>, <a href="https://code-thetadev-de--thetadev--test-actions--2-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">`Test`</a> (15.06.2024 02:30:00 UTC)
|
||||
<!--NEXT_PREV - [#3](https://code.thetadev.de/thetadev/test-actions/actions/runs/3) [[35eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/35eed48a8382513147a949117ef4aa659989d397)] <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/3" target="_blank" rel="noopener noreferrer">Artifacts</a>: <a href="https://code-thetadev-de--thetadev--test-actions--3-1.localhost:3000" target="_blank" rel="noopener noreferrer">`Hello`</a>, <a href="https://code-thetadev-de--thetadev--test-actions--3-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">`Test`</a> (15.06.2024 03:30:00 UTC) -->
|
||||
</details>
|
115
src/templates.rs
|
@ -1,19 +1,19 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{
|
||||
artifact_api::Artifact,
|
||||
cache::{ListingEntry, Size},
|
||||
cache::{Crc32, ListingEntry, Size},
|
||||
config::Config,
|
||||
error::Result,
|
||||
query::QueryData,
|
||||
query::{Query, QueryRef},
|
||||
};
|
||||
use junit_parser::TestSuites;
|
||||
use yarte::{Render, Template};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Version;
|
||||
|
||||
#[derive(Template, Default)]
|
||||
#[derive(Template)]
|
||||
#[template(path = "index")]
|
||||
pub struct Index {
|
||||
pub version: Version,
|
||||
pub struct Index<'a> {
|
||||
pub main_url: &'a str,
|
||||
pub example_site: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
|
@ -27,7 +27,6 @@ pub struct Error<'a> {
|
|||
#[template(path = "selection")]
|
||||
pub struct Selection<'a> {
|
||||
pub main_url: &'a str,
|
||||
pub version: Version,
|
||||
pub run_url: &'a str,
|
||||
pub run_name: &'a str,
|
||||
pub publisher: LinkItem,
|
||||
|
@ -38,16 +37,53 @@ pub struct Selection<'a> {
|
|||
#[template(path = "listing")]
|
||||
pub struct Listing<'a> {
|
||||
pub main_url: &'a str,
|
||||
pub version: Version,
|
||||
pub run_url: &'a str,
|
||||
pub artifact_name: &'a str,
|
||||
pub path_components: Vec<LinkItem>,
|
||||
pub n_dirs: usize,
|
||||
pub n_files: usize,
|
||||
pub has_parent: bool,
|
||||
pub publisher: LinkItem,
|
||||
pub viewer_max_size: u32,
|
||||
pub entries: Vec<ListingEntry>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "preview")]
|
||||
pub struct Preview<'a> {
|
||||
pub main_url: &'a str,
|
||||
pub run_url: &'a str,
|
||||
pub filename: &'a str,
|
||||
pub path_components: Vec<LinkItem>,
|
||||
pub publisher: LinkItem,
|
||||
pub lines: usize,
|
||||
pub size: Size,
|
||||
pub viewers: Vec<ViewerLink>,
|
||||
pub body: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "junit")]
|
||||
pub struct Junit {
|
||||
pub suites: TestSuites,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "userscript")]
|
||||
pub struct Userscript<'a> {
|
||||
pub main_url: &'a str,
|
||||
pub root_domain: &'a str,
|
||||
pub no_https: bool,
|
||||
pub forge_urls: Vec<String>,
|
||||
pub aliases: &'a BTreeMap<&'a str, &'a str>,
|
||||
}
|
||||
|
||||
pub struct ViewerLink {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
pub struct LinkItem {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
|
@ -62,24 +98,14 @@ pub struct ArtifactItem {
|
|||
}
|
||||
|
||||
impl ArtifactItem {
|
||||
pub fn from_artifact<T>(
|
||||
artifact: Artifact,
|
||||
query: &QueryData<T>,
|
||||
cfg: &Config,
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
pub fn from_artifact(artifact: Artifact, query: QueryRef<'_>, cfg: &Config) -> Self {
|
||||
Self {
|
||||
name: artifact.name,
|
||||
url: cfg.url_with_subdomain(&query.subdomain_with_artifact(Some(artifact.id))?),
|
||||
url: cfg.url_with_subdomain(&query.subdomain_with_artifact(artifact.id)),
|
||||
size: Size(artifact.size as u32),
|
||||
expired: artifact.expired,
|
||||
download_url: artifact.user_download_url.unwrap_or(artifact.download_url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Version {
|
||||
fn render(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str(env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -92,3 +118,44 @@ impl Render for Size {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Crc32 {
|
||||
fn render(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
match self.0 {
|
||||
Some(crc) => write!(f, "{crc:08x}"),
|
||||
None => f.write_str("—"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(src = "{{ rendered }}")]
|
||||
struct RenderTemplate<T: Render> {
|
||||
rendered: T,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc32() {
|
||||
let tmpl = RenderTemplate {
|
||||
rendered: Crc32(Some(0xc538cf99)),
|
||||
};
|
||||
assert_eq!(tmpl.to_string(), "c538cf99");
|
||||
|
||||
let tmpl = RenderTemplate {
|
||||
rendered: Crc32(None),
|
||||
};
|
||||
assert_eq!(tmpl.to_string(), "—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size() {
|
||||
let tmpl = RenderTemplate {
|
||||
rendered: Size(1000),
|
||||
};
|
||||
assert_eq!(tmpl.to_string(), "1 kB");
|
||||
}
|
||||
}
|
||||
|
|
100
src/util.rs
|
@ -194,7 +194,7 @@ pub fn get_ip_address(request: &Request, real_ip_header: Option<&str>) -> Result
|
|||
let socket_addr = request
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.ok_or(Error::Internal("could get request ip address".into()))?
|
||||
.ok_or(Error::Other("could get request ip address".into()))?
|
||||
.0;
|
||||
Ok(socket_addr.ip())
|
||||
}
|
||||
|
@ -241,12 +241,37 @@ pub fn parse_url(input: &str) -> Result<(&str, std::str::Split<char>)> {
|
|||
Ok((host, parts))
|
||||
}
|
||||
|
||||
pub fn time_to_ms(time: f64) -> u64 {
|
||||
(time * 1000.0) as u64
|
||||
}
|
||||
|
||||
/// Get the extension from a filename for selecting a viewer
|
||||
pub fn filename_ext(filename: &str) -> &str {
|
||||
let mut rsplit = filename.rsplit('.');
|
||||
let ext = rsplit.next().unwrap();
|
||||
if filename.starts_with('.') && rsplit.next().map(str::is_empty).unwrap_or(true) {
|
||||
// Dotfile without extension (e.g. .bashrc)
|
||||
filename
|
||||
} else {
|
||||
ext
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorJson {
|
||||
status: u16,
|
||||
msg: String,
|
||||
}
|
||||
|
||||
impl ErrorJson {
|
||||
pub fn ok<S: Into<String>>(msg: S) -> Self {
|
||||
Self {
|
||||
status: 200,
|
||||
msg: msg.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for ErrorJson {
|
||||
fn from(value: Error) -> Self {
|
||||
Self {
|
||||
|
@ -264,15 +289,62 @@ impl From<http::Error> for ErrorJson {
|
|||
|
||||
impl IntoResponse for ErrorJson {
|
||||
fn into_response(self) -> Response {
|
||||
Response::builder().json(&self).unwrap()
|
||||
Response::builder().status(self.status).json(&self).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_delim<'a>(s: &'a str, start: &str, end: &str) -> Option<&'a str> {
|
||||
if let Some(np) = s.find(start) {
|
||||
if let Some(np_end) = s[np + start.len()..].find(end) {
|
||||
return Some(s[np + start.len()..np + start.len() + np_end].trim());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn split_icon_prefix(s: &str) -> (&str, &str) {
|
||||
if let Some((i, c)) = s
|
||||
.char_indices()
|
||||
.find(|(_, c)| c.is_ascii() || c.is_alphanumeric())
|
||||
{
|
||||
if i > 0 && c == ' ' && s.get(i + 1..).is_some() {
|
||||
return (&s[..i + 1], &s[i + 1..]);
|
||||
}
|
||||
}
|
||||
("", s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use http::{header, HeaderMap};
|
||||
use once_cell::sync::Lazy;
|
||||
use path_macro::path;
|
||||
use rstest::rstest;
|
||||
|
||||
pub static TESTFILES: Lazy<PathBuf> =
|
||||
Lazy::new(|| path!(env!("CARGO_MANIFEST_DIR") / "tests" / "testfiles"));
|
||||
|
||||
static SITEDIR: Lazy<PathBuf> = Lazy::new(|| {
|
||||
let sitedir = path!(*TESTFILES / "sites_data");
|
||||
if !sitedir.is_dir() {
|
||||
std::process::Command::new(path!(*TESTFILES / "sites" / "make_zip.sh"))
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
sitedir
|
||||
});
|
||||
|
||||
pub fn setup_cache_dir(dir: &Path) {
|
||||
for entry in std::fs::read_dir(SITEDIR.as_path()).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
if entry.file_type().unwrap().is_file() {
|
||||
std::fs::copy(entry.path(), path!(dir / entry.file_name())).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("", false)]
|
||||
#[case("br", false)]
|
||||
|
@ -320,4 +392,26 @@ mod tests {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("hello.txt", "txt")]
|
||||
#[case(".bashrc", ".bashrc")]
|
||||
#[case("Makefile", "Makefile")]
|
||||
#[case("", "")]
|
||||
fn filename_ext(#[case] filename: &str, #[case] expect: &str) {
|
||||
let res = super::filename_ext(filename);
|
||||
assert_eq!(res, expect);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("🧪 Test", ("🧪 ", "Test"))]
|
||||
#[case("🧪👨👩👦 Test", ("🧪👨👩👦 ", "Test"))]
|
||||
#[case("🧪 👨👩👦 Test", ("🧪 ", "👨👩👦 Test"))]
|
||||
#[case("", ("", ""))]
|
||||
#[case("Test", ("", "Test"))]
|
||||
#[case("運命 Test", ("", "運命 Test"))]
|
||||
fn split_icon_prefix(#[case] s: &str, #[case] expect: (&str, &str)) {
|
||||
let res = super::split_icon_prefix(s);
|
||||
assert_eq!(res, expect);
|
||||
}
|
||||
}
|
||||
|
|
91
src/viewer/code.rs
Normal file
|
@ -0,0 +1,91 @@
|
|||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use syntect::{
|
||||
html::{ClassStyle, ClassedHTMLGenerator},
|
||||
parsing::SyntaxSet,
|
||||
util::LinesWithEndings,
|
||||
};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
use super::Viewer;
|
||||
|
||||
pub struct CodeViewer {
|
||||
ss: Arc<SyntaxSet>,
|
||||
smap: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl CodeViewer {
|
||||
pub fn new(ss: Arc<SyntaxSet>) -> Self {
|
||||
let smap = ss
|
||||
.syntaxes()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, s)| s.file_extensions.iter().map(move |ext| (ext.to_owned(), i)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
Self { ss, smap }
|
||||
}
|
||||
}
|
||||
|
||||
impl Viewer for CodeViewer {
|
||||
fn id(&self) -> &'static str {
|
||||
"code"
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Code"
|
||||
}
|
||||
|
||||
fn is_applicable(&self, _filename: &str, ext: &str) -> bool {
|
||||
self.smap.contains_key(ext)
|
||||
}
|
||||
|
||||
fn try_render(&self, _filename: &str, ext: &str, data: &str) -> Result<String, Error> {
|
||||
let i = self.smap.get(ext).ok_or(Error::ViewerNotApplicable)?;
|
||||
let syntax = &self.ss.syntaxes()[*i];
|
||||
|
||||
let mut html_generator =
|
||||
ClassedHTMLGenerator::new_with_class_style(syntax, &self.ss, ClassStyle::Spaced);
|
||||
LinesWithEndings::from(data)
|
||||
.try_for_each(|line| html_generator.parse_html_for_line_which_includes_newline(line))
|
||||
.map_err(|e| Error::Viewer(e.to_string().into()))?;
|
||||
|
||||
Ok(format!(
|
||||
"<pre><code>{}</code></pre>",
|
||||
html_generator.finalize()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_applicable() {
|
||||
let ss = Arc::new(SyntaxSet::load_defaults_newlines());
|
||||
let cv = CodeViewer::new(ss);
|
||||
assert!(cv.is_applicable("hello.txt", "txt"));
|
||||
assert!(cv.is_applicable(".bashrc", ".bashrc"));
|
||||
assert!(!cv.is_applicable("image.jpg", "jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render() {
|
||||
let ss = Arc::new(SyntaxSet::load_defaults_newlines());
|
||||
let cv = CodeViewer::new(ss);
|
||||
let res = cv
|
||||
.try_render(
|
||||
"hello.rs",
|
||||
"rs",
|
||||
r#"fn test() {
|
||||
let x = "World";
|
||||
println!("Hello {x}");
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
insta::assert_snapshot!(res);
|
||||
}
|
||||
}
|
56
src/viewer/junit.rs
Normal file
|
@ -0,0 +1,56 @@
|
|||
use crate::{error::Error, templates};
|
||||
|
||||
use super::Viewer;
|
||||
|
||||
/// JUnit format documentation: https://llg.cubic.org/docs/junit/
|
||||
pub struct JunitViewer;
|
||||
|
||||
impl Viewer for JunitViewer {
|
||||
fn id(&self) -> &'static str {
|
||||
"junit"
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"JUnit"
|
||||
}
|
||||
|
||||
fn is_applicable(&self, filename: &str, ext: &str) -> bool {
|
||||
ext == "xml" && filename.contains("junit")
|
||||
}
|
||||
|
||||
fn try_render(&self, filename: &str, _ext: &str, data: &str) -> Result<String, Error> {
|
||||
let suites = junit_parser::from_str(data).map_err(|e| {
|
||||
tracing::error!("could not parse junit report {filename}: {e}");
|
||||
Error::ViewerNotApplicable
|
||||
})?;
|
||||
|
||||
let tmpl = templates::Junit { suites };
|
||||
Ok(tmpl.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use path_macro::path;
|
||||
|
||||
use crate::util::tests::TESTFILES;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_applicable() {
|
||||
let ju = JunitViewer;
|
||||
assert!(ju.is_applicable("junit.xml", "xml"));
|
||||
assert!(ju.is_applicable("hello.junit.xml", "xml"));
|
||||
assert!(!ju.is_applicable("hello.xml", "xml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render() {
|
||||
let ju = JunitViewer;
|
||||
let data =
|
||||
std::fs::read_to_string(path!(*TESTFILES / "junit" / "hello.junit.xml")).unwrap();
|
||||
let res = ju.try_render("hello.junit.xml", "xml", &data).unwrap();
|
||||
insta::assert_snapshot!(res);
|
||||
}
|
||||
}
|
140
src/viewer/markdown.rs
Normal file
|
@ -0,0 +1,140 @@
|
|||
use std::{collections::HashMap, io::Write, sync::Arc};
|
||||
|
||||
use comrak::adapters::SyntaxHighlighterAdapter;
|
||||
use syntect::{
|
||||
html::{ClassStyle, ClassedHTMLGenerator},
|
||||
parsing::SyntaxSet,
|
||||
util::LinesWithEndings,
|
||||
};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
use super::Viewer;
|
||||
|
||||
pub struct MarkdownViewer {
|
||||
adapter: SyntectAdapter,
|
||||
}
|
||||
|
||||
impl MarkdownViewer {
|
||||
pub fn new(ss: Arc<SyntaxSet>) -> Self {
|
||||
Self {
|
||||
adapter: SyntectAdapter { ss },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Viewer for MarkdownViewer {
|
||||
fn id(&self) -> &'static str {
|
||||
"md"
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Markdown"
|
||||
}
|
||||
|
||||
fn is_applicable(&self, _filename: &str, ext: &str) -> bool {
|
||||
ext == "md"
|
||||
}
|
||||
|
||||
fn try_render(&self, _filename: &str, _ext: &str, data: &str) -> Result<String, Error> {
|
||||
let mut options = comrak::Options::default();
|
||||
options.extension.autolink = true;
|
||||
options.extension.table = true;
|
||||
options.extension.tasklist = true;
|
||||
options.extension.strikethrough = true;
|
||||
options.extension.multiline_block_quotes = true;
|
||||
options.extension.superscript = true;
|
||||
|
||||
let mut plugins = comrak::Plugins::default();
|
||||
plugins.render.codefence_syntax_highlighter = Some(&self.adapter);
|
||||
|
||||
let html = comrak::markdown_to_html_with_plugins(data, &options, &plugins);
|
||||
|
||||
Ok(format!("<div class=\"prose\">{html}</div>"))
|
||||
}
|
||||
}
|
||||
|
||||
struct SyntectAdapter {
|
||||
ss: Arc<SyntaxSet>,
|
||||
}
|
||||
|
||||
impl SyntaxHighlighterAdapter for SyntectAdapter {
|
||||
fn write_highlighted(
|
||||
&self,
|
||||
output: &mut dyn Write,
|
||||
lang: Option<&str>,
|
||||
code: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let fallback_syntax = "Plain Text";
|
||||
|
||||
let lang: &str = match lang {
|
||||
Some(l) if !l.is_empty() => l,
|
||||
_ => fallback_syntax,
|
||||
};
|
||||
|
||||
let syntax = self.ss.find_syntax_by_token(lang).unwrap_or_else(|| {
|
||||
self.ss
|
||||
.find_syntax_by_first_line(code)
|
||||
.unwrap_or_else(|| self.ss.find_syntax_plain_text())
|
||||
});
|
||||
|
||||
let mut html_generator =
|
||||
ClassedHTMLGenerator::new_with_class_style(syntax, &self.ss, ClassStyle::Spaced);
|
||||
|
||||
if let Err(e) = LinesWithEndings::from(code)
|
||||
.try_for_each(|line| html_generator.parse_html_for_line_which_includes_newline(line))
|
||||
{
|
||||
tracing::error!("rendering md code: {e}");
|
||||
return output.write_all(code.as_bytes());
|
||||
}
|
||||
|
||||
let html = html_generator.finalize();
|
||||
output.write_all(html.as_bytes())
|
||||
}
|
||||
|
||||
fn write_pre_tag(
|
||||
&self,
|
||||
output: &mut dyn Write,
|
||||
_attributes: HashMap<String, String>,
|
||||
) -> std::io::Result<()> {
|
||||
output.write_all(b"<pre>")
|
||||
}
|
||||
|
||||
fn write_code_tag(
|
||||
&self,
|
||||
output: &mut dyn Write,
|
||||
_attributes: HashMap<String, String>,
|
||||
) -> std::io::Result<()> {
|
||||
output.write_all(b"<code>")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_applicable() {
|
||||
let ss = Arc::new(SyntaxSet::load_defaults_newlines());
|
||||
let mv = MarkdownViewer::new(ss);
|
||||
assert!(mv.is_applicable("hello.md", "md"));
|
||||
assert!(!mv.is_applicable("hello.txt", "txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render() {
|
||||
let ss = Arc::new(SyntaxSet::load_defaults_newlines());
|
||||
let mv = MarkdownViewer::new(ss);
|
||||
let res = mv
|
||||
.try_render(
|
||||
"hello.md",
|
||||
"md",
|
||||
r#"# Hello World
|
||||
|
||||
this is a small paragraph for *testing*.
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
insta::assert_snapshot!(res);
|
||||
}
|
||||
}
|
118
src/viewer/mod.rs
Normal file
|
@ -0,0 +1,118 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use syntect::parsing::SyntaxSet;
|
||||
|
||||
use crate::{error::Error, templates::ViewerLink, util};
|
||||
|
||||
mod code;
|
||||
mod junit;
|
||||
mod markdown;
|
||||
|
||||
pub trait Viewer: Sync + Send {
|
||||
fn id(&self) -> &'static str;
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
fn is_applicable(&self, filename: &str, ext: &str) -> bool;
|
||||
fn try_render(&self, filename: &str, ext: &str, data: &str) -> Result<String, Error>;
|
||||
}
|
||||
|
||||
pub struct Viewers {
|
||||
viewers: [Box<dyn Viewer>; 3],
|
||||
}
|
||||
|
||||
pub struct RenderRes {
|
||||
/// Body html
|
||||
pub html: String,
|
||||
/// List of applicable viewers to be inserted into the top bar
|
||||
pub tmpl_viewers: Vec<ViewerLink>,
|
||||
}
|
||||
|
||||
impl Viewers {
|
||||
pub fn new() -> Self {
|
||||
let ss = Arc::new(SyntaxSet::load_defaults_newlines());
|
||||
Self {
|
||||
viewers: [
|
||||
Box::new(junit::JunitViewer),
|
||||
Box::new(markdown::MarkdownViewer::new(ss.clone())),
|
||||
Box::new(code::CodeViewer::new(ss)),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_render(&self, filename: &str, viewer: &str, data: &str) -> Result<RenderRes, Error> {
|
||||
let ext = util::filename_ext(filename);
|
||||
|
||||
if !viewer.is_empty() && viewer != "1" {
|
||||
if let Some(viewer) = self.viewers.iter().find(|v| v.id() == viewer) {
|
||||
if viewer.is_applicable(filename, ext) {
|
||||
return viewer
|
||||
.try_render(filename, ext, data)
|
||||
.map(|html| RenderRes {
|
||||
html,
|
||||
tmpl_viewers: self.tmpl_viewers(viewer.id(), filename, ext),
|
||||
});
|
||||
} else {
|
||||
return Err(Error::ViewerNotApplicable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for viewer in self
|
||||
.viewers
|
||||
.iter()
|
||||
.filter(|v| v.is_applicable(filename, ext))
|
||||
{
|
||||
match viewer.try_render(filename, ext, data) {
|
||||
Ok(html) => {
|
||||
return Ok(RenderRes {
|
||||
html,
|
||||
tmpl_viewers: self.tmpl_viewers(viewer.id(), filename, ext),
|
||||
})
|
||||
}
|
||||
Err(Error::ViewerNotApplicable) => {}
|
||||
Err(e) => {
|
||||
tracing::error!("could not render {filename}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(Error::ViewerNotApplicable)
|
||||
}
|
||||
|
||||
fn tmpl_viewers(&self, viewer: &str, filename: &str, ext: &str) -> Vec<ViewerLink> {
|
||||
self.viewers
|
||||
.iter()
|
||||
.filter(|v| v.is_applicable(filename, ext))
|
||||
.map(|v| ViewerLink {
|
||||
id: v.id(),
|
||||
name: v.name(),
|
||||
selected: v.id() == viewer,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case("test.txt", "", &["code"])]
|
||||
#[case("hello.md", "", &["md", "code"])]
|
||||
#[case("junit.xml", r#"<?xml version="1.0" encoding="UTF-8"?> <testsuites></testsuites>"#, &["junit", "code"])]
|
||||
#[case("img.png", "", &[])]
|
||||
fn render(#[case] filename: &str, #[case] data: &str, #[case] applicable: &[&str]) {
|
||||
let viewers = Viewers::new();
|
||||
let res = viewers.try_render(filename, "1", data);
|
||||
|
||||
if applicable.is_empty() {
|
||||
assert!(matches!(res, Err(Error::ViewerNotApplicable)));
|
||||
} else {
|
||||
let res = res.unwrap();
|
||||
assert!(res.tmpl_viewers[0].selected);
|
||||
let renderers = res.tmpl_viewers.iter().map(|v| v.id).collect::<Vec<_>>();
|
||||
assert_eq!(renderers, applicable)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
source: src/viewer/code.rs
|
||||
assertion_line: 89
|
||||
expression: res
|
||||
---
|
||||
<pre><code><span class="source rust"><span class="meta function rust"><span class="meta function rust"><span class="storage type function rust">fn</span> </span><span class="entity name function rust">test</span></span><span class="meta function rust"><span class="meta function parameters rust"><span class="punctuation section parameters begin rust">(</span></span><span class="meta function rust"><span class="meta function parameters rust"><span class="punctuation section parameters end rust">)</span></span></span></span><span class="meta function rust"> </span><span class="meta function rust"><span class="meta block rust"><span class="punctuation section block begin rust">{</span>
|
||||
<span class="storage type rust">let</span> x <span class="keyword operator rust">=</span> <span class="string quoted double rust"><span class="punctuation definition string begin rust">"</span>World<span class="punctuation definition string end rust">"</span></span><span class="punctuation terminator rust">;</span>
|
||||
<span class="support macro rust">println!</span><span class="meta group rust"><span class="punctuation section group begin rust">(</span></span><span class="meta group rust"><span class="string quoted double rust"><span class="punctuation definition string begin rust">"</span>Hello <span class="constant other placeholder rust">{x}</span><span class="punctuation definition string end rust">"</span></span></span><span class="meta group rust"><span class="punctuation section group end rust">)</span></span><span class="punctuation terminator rust">;</span>
|
||||
</span><span class="meta block rust"><span class="punctuation section block end rust">}</span></span></span>
|
||||
</span></code></pre>
|
|
@ -0,0 +1,197 @@
|
|||
---
|
||||
source: src/viewer/junit.rs
|
||||
assertion_line: 54
|
||||
expression: res
|
||||
---
|
||||
<div class="junit">
|
||||
<div id="junit-suites">
|
||||
<p class="coltitle">Test suites:</p>
|
||||
<ul>
|
||||
<li><button class="active">Show all</button></li>
|
||||
|
||||
<li data-status="failure"><button>lib1</button></li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
<div id="junit-cases">
|
||||
<p class="coltitle">Test cases:</p>
|
||||
<p id="junit-statusfilter" class="colsubtitle">
|
||||
<button data-status="all">All <b>?</b></button>
|
||||
<button data-status="success">OK <b>?</b></button>
|
||||
<button data-status="failure">Failed <b>?</b></button>
|
||||
<button data-status="error">Error <b>?</b></button>
|
||||
<button data-status="flaky">Flaky <b>?</b></button>
|
||||
<button data-status="skipped">Skipped <b>?</b></button>
|
||||
</p>
|
||||
<ul>
|
||||
<li data-suite="lib1" data-status="success">
|
||||
<button><span>lib1::</span>tests::it_works</button>
|
||||
<div class="pvcontent">
|
||||
<h2><i class="gg-check-o"></i> lib1::tests::it_works</h2>
|
||||
<p class="badges"><span>Success</span><span>3ms</span></p><pre><code>
|
||||
running 1 test
|
||||
test tests::it_works ... ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s
|
||||
|
||||
</code></pre>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li data-suite="lib1" data-status="failure">
|
||||
<button><span>lib1::</span>tests::pippi_langstrumpf</button>
|
||||
<div class="pvcontent">
|
||||
<h2><i class="gg-close-o"></i> lib1::tests::pippi_langstrumpf</h2>
|
||||
<p class="badges"><span>Failure</span><span>3ms</span></p><pre><code>thread 'tests::pippi_langstrumpf' panicked at src/lib.rs:18:9:
|
||||
assertion `left == right` failed
|
||||
left: 7
|
||||
right: 9
|
||||
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace</code></pre><pre><code>
|
||||
running 1 test
|
||||
test tests::pippi_langstrumpf ... FAILED
|
||||
|
||||
failures:
|
||||
|
||||
failures:
|
||||
tests::pippi_langstrumpf
|
||||
|
||||
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s
|
||||
|
||||
</code></pre><pre><code>thread 'tests::pippi_langstrumpf' panicked at src/lib.rs:18:9:
|
||||
assertion `left == right` failed
|
||||
left: 7
|
||||
right: 9
|
||||
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
|
||||
</code></pre>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
<div id="junit-preview">
|
||||
<div id="preview-margin"></div>
|
||||
<div class="prose">
|
||||
<p class="light">Select a test case to show details</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
|
||||
|
||||
let preview;
|
||||
let previewMargin;
|
||||
let statusFilterBtns = {};
|
||||
let filterSuite = null;
|
||||
let filterStatus = "failure";
|
||||
let lastScrollPos = 0;
|
||||
let previewMarginH = 0;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
preview = document.querySelector("#junit-preview > .prose");
|
||||
previewMargin = document.getElementById("preview-margin");
|
||||
|
||||
statusFilterBtns = {};
|
||||
const btnElms = document.getElementById("junit-statusfilter").children;
|
||||
for (let i=0; i<btnElms.length; i++) {
|
||||
const elm = btnElms[i];
|
||||
statusFilterBtns[elm.attributes["data-status"].value] = elm;
|
||||
}
|
||||
|
||||
document.querySelectorAll("#junit-suites li > button").forEach((btn) => {
|
||||
btn.addEventListener("click", () => filterBySuite(btn));
|
||||
});
|
||||
document.querySelectorAll("#junit-statusfilter > button").forEach((btn) => {
|
||||
btn.addEventListener("click", () => filterByStatus(btn));
|
||||
});
|
||||
document.querySelectorAll("#junit-cases li > button").forEach((btn) => {
|
||||
btn.addEventListener("click", selectTestCase);
|
||||
});
|
||||
doFilter();
|
||||
|
||||
document.addEventListener("scroll", () => {
|
||||
const delta = lastScrollPos - document.documentElement.scrollTop;
|
||||
if (delta > 0) {
|
||||
previewMarginH = Math.max(previewMarginH - delta, 0);
|
||||
lastScrollPos = document.documentElement.scrollTop;
|
||||
previewMargin.style.marginTop = previewMarginH + "px";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function setCls(elm, cls, val) {
|
||||
if (val) elm.classList.add(cls);
|
||||
else elm.classList.remove(cls);
|
||||
}
|
||||
|
||||
function selectTestCase(event) {
|
||||
let elm = event.target.closest("li");
|
||||
const pvc = elm.querySelector(".pvcontent");
|
||||
if (pvc && preview) {
|
||||
preview.innerHTML = pvc.innerHTML;
|
||||
preview.parentElement.setAttribute("data-status", elm.attributes["data-status"].value);
|
||||
|
||||
previewMarginH = Math.max(document.documentElement.scrollTop - 72, 20);
|
||||
lastScrollPos = document.documentElement.scrollTop;
|
||||
previewMargin.style.marginTop = previewMarginH + "px";
|
||||
|
||||
resetBtns("junit-cases ul");
|
||||
elm.querySelector("button").classList.add("active");
|
||||
if (window.innerWidth < 1000) preview.scrollIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
function resetBtns(id) {
|
||||
document.querySelectorAll(`#${id} .active`).forEach((elm) => {
|
||||
elm.classList.remove("active");
|
||||
});
|
||||
}
|
||||
|
||||
function doFilter() {
|
||||
const nStatus = {all: 0};
|
||||
document.querySelectorAll("#junit-cases li").forEach((elm) => {
|
||||
const status = elm.attributes["data-status"].value;
|
||||
const isSuite = filterSuite === null || filterSuite === elm.attributes["data-suite"].value;
|
||||
const vis = isSuite && (filterStatus === "all" || filterStatus === status);
|
||||
setCls(elm, "hidden", !vis);
|
||||
if (isSuite) {
|
||||
nStatus[status] = nStatus[status] ? nStatus[status]+1 : 1;
|
||||
nStatus.all++;
|
||||
}
|
||||
});
|
||||
|
||||
if (!nStatus[filterStatus]) {
|
||||
filterStatus = "all";
|
||||
doFilter();
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(statusFilterBtns).forEach(([status, elm]) => {
|
||||
const n = nStatus[status] ?? 0;
|
||||
elm.children[0].textContent = n;
|
||||
if (status === filterStatus) elm.classList.add("active");
|
||||
else elm.classList.remove("active");
|
||||
setCls(elm, "hidden", n === 0);
|
||||
});
|
||||
setCls(document.getElementById("junit-cases"), "filtered", filterSuite);
|
||||
}
|
||||
|
||||
function filterBySuite(btn) {
|
||||
const suite = btn.textContent;
|
||||
if (suite) {
|
||||
filterSuite = suite === "Show all" ? null : suite;
|
||||
doFilter();
|
||||
resetBtns("junit-suites");
|
||||
btn.classList.add("active");
|
||||
}
|
||||
}
|
||||
|
||||
function filterByStatus(btn) {
|
||||
filterStatus = btn.attributes["data-status"].value;
|
||||
doFilter();
|
||||
}
|
||||
|
||||
// @license-end
|
||||
|
||||
</script>
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
source: src/viewer/markdown.rs
|
||||
assertion_line: 138
|
||||
expression: res
|
||||
---
|
||||
<div class="prose"><h1>Hello World</h1>
|
||||
<p>this is a small paragraph for <em>testing</em>.</p>
|
||||
</div>
|
|
@ -1,21 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
* { padding: 0; margin: 0; --color-secondary: #dedede; --color-text: #000;
|
||||
--color-text-light: #888; --color-border: #ccc; } body { font-family: sans-serif;
|
||||
text-rendering: optimizespeed; background-color: #f5f5f5; color:
|
||||
var(--color-text); } a { color: #006ed3; text-decoration: none; } a:hover { color:
|
||||
#319cff; } .card { display: flex; flex-direction: column; width: 90%; max-width:
|
||||
500px; align-items: center; } .input-row { display: flex; width: 100%; } .center {
|
||||
width: 100%; display: flex; flex-direction: row; justify-content: center; } .light
|
||||
{ color: var(--color-text-light); } p { margin: 16px 0; } header { gap: 1em;
|
||||
padding-top: 10px; padding-bottom: 10px; background-color: #f2f2f2; } footer {
|
||||
padding: 40px 20px; font-size: 12px; text-align: center; } @media
|
||||
(prefers-color-scheme: dark) { * { --color-secondary: #082437; --color-border:
|
||||
#212121; --color-text: #dddddd; } body { background-color: #101010; } header {
|
||||
* { padding: 0; margin: 0; --color-text: #000; --color-text-light: #888; } body {
|
||||
font-family: sans-serif; text-rendering: optimizespeed; background-color: #f5f5f5;
|
||||
color: var(--color-text); } a { color: #006ed3; text-decoration: none; } a:hover {
|
||||
color: #319cff; } .card { display: flex; flex-direction: column; width: 90%;
|
||||
max-width: 500px; align-items: center; } .center { width: 100%; display: flex;
|
||||
flex-direction: row; justify-content: center; } .light { color:
|
||||
var(--color-text-light); } p { margin: 16px 0; } header { gap: 1em; padding-top:
|
||||
10px; padding-bottom: 10px; background-color: #f2f2f2; } footer { padding: 40px
|
||||
20px; font-size: 12px; text-align: center; } @media (prefers-color-scheme: dark) {
|
||||
* { --color-text: #dddddd; } body { background-color: #101010; } header {
|
||||
background-color: #151515; } }
|
||||
</style>
|
||||
<title>Artifactview</title>
|
||||
|
|
|
@ -1,28 +1,6 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
* { padding: 0; margin: 0; --color-secondary: #dedede; --color-text: #000;
|
||||
--color-text-light: #888; --color-border: #ccc; } body { font-family: sans-serif;
|
||||
text-rendering: optimizespeed; background-color: #f5f5f5; color:
|
||||
var(--color-text);} a { color: #006ed3; text-decoration: none; } a:hover { color:
|
||||
#319cff; } .card { display: flex; flex-direction: column; width: 90%; max-width:
|
||||
500px; align-items: center; } .input-row { display: flex; width: 100%; } .center {
|
||||
width: 100%; display: flex; flex-direction: row; justify-content: center; } .light
|
||||
{ color: var(--color-text-light); } input { color: inherit; font-size: 16px;
|
||||
height: 32px; border: 1px solid var(--color-border); padding: 4px 8px; } button {
|
||||
background-color: #006ed3; color: #fff; padding: 4px 8px; border: none; cursor:
|
||||
pointer; } button:hover { opacity: 0.7; } p { margin: 16px 0; } header { gap: 1em;
|
||||
padding-top: 10px; padding-bottom: 10px; background-color: #f2f2f2; } footer {
|
||||
padding: 40px 20px; font-size: 12px; text-align: center; } @media
|
||||
(prefers-color-scheme: dark) { * { --color-text: #dddddd; --color-secondary:
|
||||
#082437; --color-border: #212121; } body { background-color: #101010; } input
|
||||
{background-color: #151515;} header { background-color: #151515; }}
|
||||
</style>
|
||||
{{#> partial/header ~}}
|
||||
<title>Artifactview</title>
|
||||
</head>
|
||||
<body>
|
||||
{{~/partial/header }}
|
||||
<header class="center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
@ -40,26 +18,32 @@
|
|||
<div class="center">
|
||||
<div class="card">
|
||||
<p>Enter a GitHub/Gitea/Forgejo Actions run url to browse CI artifacts</p>
|
||||
<form method="POST" class="input-row">
|
||||
<form method="GET" class="input-row">
|
||||
<input
|
||||
class="query-input"
|
||||
name="url"
|
||||
type="text"
|
||||
placeholder="codeberg.org/username/repo/actions/runs/42"
|
||||
required
|
||||
placeholder="{{example_site}}/user/repo/actions/runs/42"
|
||||
style="flex-grow: 1"
|
||||
/>
|
||||
<button type="submit">Browse</button>
|
||||
<button class="btn" type="submit">Browse</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<a
|
||||
href="https://code.thetadev.de/ThetaDev/artifactview"
|
||||
href="https://codeberg.org/ThetaDev/artifactview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Artifactview
|
||||
</a>
|
||||
{{version}}
|
||||
{{~crate::app::VERSION}}
|
||||
<p class="light">
|
||||
Install the <a href="/artifactview.user.js">Artifactview userscript</a> for <a href="https://addons.mozilla.org/de/firefox/addon/greasemonkey/" target="_blank" rel="noopener noreferrer">Greasemonkey</a>/<a href="https://violentmonkey.github.io/" target="_blank" rel="noopener noreferrer">Violentmonkey</a>
|
||||
to add a "View artifact" button to your code forge.
|
||||
</p>
|
||||
<p class="light">
|
||||
<b>Disclaimer:</b>
|
||||
Artifactview does not host any websites, the data is fetched from the respective
|
||||
|
|
183
templates/junit.hbs
Normal file
|
@ -0,0 +1,183 @@
|
|||
<div class="junit">
|
||||
<div id="junit-suites">
|
||||
<p class="coltitle">Test suites:</p>
|
||||
<ul>
|
||||
<li><button class="active">Show all</button></li>
|
||||
{{#each suites.suites}}
|
||||
<li data-status="{{#if errors == 0 && failures == 0}}success{{else}}failure{{/if}}"><button>{{name}}</button></li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
<div id="junit-cases">
|
||||
<p class="coltitle">Test cases:</p>
|
||||
<p id="junit-statusfilter" class="colsubtitle">
|
||||
<button data-status="all">All <b>?</b></button>
|
||||
<button data-status="success">OK <b>?</b></button>
|
||||
<button data-status="failure">Failed <b>?</b></button>
|
||||
<button data-status="error">Error <b>?</b></button>
|
||||
<button data-status="flaky">Flaky <b>?</b></button>
|
||||
<button data-status="skipped">Skipped <b>?</b></button>
|
||||
</p>
|
||||
<ul>
|
||||
{{~#each suites.suites ~}}{{ let suite_name = &name }}
|
||||
{{#each cases}}
|
||||
<li data-suite="{{ suite_name }}" data-status="{{ status.id() }}">
|
||||
<button>{{#if let Some(cn) = &classname}}<span>{{ cn }}::</span>{{/if}}{{ original_name }}</button>
|
||||
<div class="pvcontent">
|
||||
<h2><i class="gg-{{#if status.id() == "success"}}check-o{{else if status.id() == "skipped"}}block{{else if status.id() == "flaky"}}danger{{else}}close-o{{/if}}"></i> {{name}}</h2>
|
||||
<p class="badges"><span>{{ this.status_txt() }}</span><span>{{ crate::util::time_to_ms(time) }}ms</span></p>
|
||||
{{~#if let Some(msg) = status.message() ~}}
|
||||
<pre><code>{{msg.message}}{{msg.text}}</code></pre>
|
||||
{{~/if}}
|
||||
{{~#if let Some(ref stdout) = system_out ~}}
|
||||
<pre><code>{{stdout}}</code></pre>
|
||||
{{~/if}}
|
||||
{{~#if let Some(ref stderr) = system_err ~}}
|
||||
<pre><code>{{stderr}}</code></pre>
|
||||
{{~/if}}
|
||||
{{~#each retries ~}}
|
||||
<h3>Failed attempt #{{index}}</h3>
|
||||
<p class="badges"><span>{{ crate::util::time_to_ms(time) }}ms</span></p>
|
||||
{{~#if let Some(msg) = status.message() ~}}
|
||||
<pre><code>{{msg.message}}{{msg.text}}</code></pre>
|
||||
{{~/if}}
|
||||
{{~#if let Some(ref stdout) = system_out ~}}
|
||||
<pre><code>{{stdout}}</code></pre>
|
||||
{{~/if}}
|
||||
{{~#if let Some(ref stderr) = system_err ~}}
|
||||
<pre><code>{{stderr}}</code></pre>
|
||||
{{~/if}}
|
||||
{{~/each}}
|
||||
</div>
|
||||
</li>
|
||||
{{/each}}
|
||||
{{~/each}}
|
||||
</ul>
|
||||
</div>
|
||||
<div id="junit-preview">
|
||||
<div id="preview-margin"></div>
|
||||
<div class="prose">
|
||||
<p class="light">Select a test case to show details</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
|
||||
|
||||
let preview;
|
||||
let previewMargin;
|
||||
let statusFilterBtns = {};
|
||||
let filterSuite = null;
|
||||
let filterStatus = "failure";
|
||||
let lastScrollPos = 0;
|
||||
let previewMarginH = 0;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
preview = document.querySelector("#junit-preview > .prose");
|
||||
previewMargin = document.getElementById("preview-margin");
|
||||
|
||||
statusFilterBtns = {};
|
||||
const btnElms = document.getElementById("junit-statusfilter").children;
|
||||
for (let i=0; i<btnElms.length; i++) {
|
||||
const elm = btnElms[i];
|
||||
statusFilterBtns[elm.attributes["data-status"].value] = elm;
|
||||
}
|
||||
|
||||
document.querySelectorAll("#junit-suites li > button").forEach((btn) => {
|
||||
btn.addEventListener("click", () => filterBySuite(btn));
|
||||
});
|
||||
document.querySelectorAll("#junit-statusfilter > button").forEach((btn) => {
|
||||
btn.addEventListener("click", () => filterByStatus(btn));
|
||||
});
|
||||
document.querySelectorAll("#junit-cases li > button").forEach((btn) => {
|
||||
btn.addEventListener("click", selectTestCase);
|
||||
});
|
||||
doFilter();
|
||||
|
||||
document.addEventListener("scroll", () => {
|
||||
const delta = lastScrollPos - document.documentElement.scrollTop;
|
||||
if (delta > 0) {
|
||||
previewMarginH = Math.max(previewMarginH - delta, 0);
|
||||
lastScrollPos = document.documentElement.scrollTop;
|
||||
previewMargin.style.marginTop = previewMarginH + "px";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function setCls(elm, cls, val) {
|
||||
if (val) elm.classList.add(cls);
|
||||
else elm.classList.remove(cls);
|
||||
}
|
||||
|
||||
function selectTestCase(event) {
|
||||
let elm = event.target.closest("li");
|
||||
const pvc = elm.querySelector(".pvcontent");
|
||||
if (pvc && preview) {
|
||||
preview.innerHTML = pvc.innerHTML;
|
||||
preview.parentElement.setAttribute("data-status", elm.attributes["data-status"].value);
|
||||
|
||||
previewMarginH = Math.max(document.documentElement.scrollTop - 72, 20);
|
||||
lastScrollPos = document.documentElement.scrollTop;
|
||||
previewMargin.style.marginTop = previewMarginH + "px";
|
||||
|
||||
resetBtns("junit-cases ul");
|
||||
elm.querySelector("button").classList.add("active");
|
||||
if (window.innerWidth < 1000) preview.scrollIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
function resetBtns(id) {
|
||||
document.querySelectorAll(`#${id} .active`).forEach((elm) => {
|
||||
elm.classList.remove("active");
|
||||
});
|
||||
}
|
||||
|
||||
function doFilter() {
|
||||
const nStatus = {all: 0};
|
||||
document.querySelectorAll("#junit-cases li").forEach((elm) => {
|
||||
const status = elm.attributes["data-status"].value;
|
||||
const isSuite = filterSuite === null || filterSuite === elm.attributes["data-suite"].value;
|
||||
const vis = isSuite && (filterStatus === "all" || filterStatus === status);
|
||||
setCls(elm, "hidden", !vis);
|
||||
if (isSuite) {
|
||||
nStatus[status] = nStatus[status] ? nStatus[status]+1 : 1;
|
||||
nStatus.all++;
|
||||
}
|
||||
});
|
||||
|
||||
if (!nStatus[filterStatus]) {
|
||||
filterStatus = "all";
|
||||
doFilter();
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(statusFilterBtns).forEach(([status, elm]) => {
|
||||
const n = nStatus[status] ?? 0;
|
||||
elm.children[0].textContent = n;
|
||||
if (status === filterStatus) elm.classList.add("active");
|
||||
else elm.classList.remove("active");
|
||||
setCls(elm, "hidden", n === 0);
|
||||
});
|
||||
setCls(document.getElementById("junit-cases"), "filtered", filterSuite);
|
||||
}
|
||||
|
||||
function filterBySuite(btn) {
|
||||
const suite = btn.textContent;
|
||||
if (suite) {
|
||||
filterSuite = suite === "Show all" ? null : suite;
|
||||
doFilter();
|
||||
resetBtns("junit-suites");
|
||||
btn.classList.add("active");
|
||||
}
|
||||
}
|
||||
|
||||
function filterByStatus(btn) {
|
||||
filterStatus = btn.attributes["data-status"].value;
|
||||
doFilter();
|
||||
}
|
||||
|
||||
// @license-end
|
||||
|
||||
</script>
|
|
@ -1,112 +1,18 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<style type="text/css">
|
||||
* {padding: 0;margin: 0;--color-secondary: #dedede;--color-text:
|
||||
#000;--color-text-light: #888;}body {font-family: sans-serif;text-rendering:
|
||||
optimizespeed;background-color: #f5f5f5;color: var(--color-text);}a {color:
|
||||
#006ed3;text-decoration: none;}a:hover {color: #319cff;}#summary, header {padding:
|
||||
0 20px;}header {display: flex;flex-direction: row;gap: 1em;padding-top:
|
||||
25px;padding-bottom: 15px;background-color: #f2f2f2;}header h1 {font-size:
|
||||
20px;font-weight: normal;white-space: nowrap;overflow-x: hidden;text-overflow:
|
||||
ellipsis;color: #999;}header h1 a {color: var(--color-text);margin: 0 4px;}footer
|
||||
a:hover, header h1 a:hover {text-decoration: underline;}header h1 a:first-child
|
||||
{margin: 0;}main {display: block;}.meta {font-size: 12px;font-family: Verdana,
|
||||
sans-serif;border-bottom: 1px solid #9c9c9c;padding-top: 10px;padding-bottom:
|
||||
10px;}.meta-item {margin-right: 1em;}#filter {padding: 4px;border: 1px solid
|
||||
#ccc;}#list {width: 100%;border-collapse: collapse;}#list tr {border-bottom: 1px
|
||||
dashed #dadada;}#list tbody tr:hover {background-color: #ffffec;}#list td, #list
|
||||
th {text-align: left;padding: 10px 0;}#list th {padding-top: 15px;padding-bottom:
|
||||
15px;font-size: 16px;white-space: nowrap;}#list th a {color:
|
||||
var(--color-text);}#list th svg {vertical-align: middle;}#list td {white-space:
|
||||
nowrap;font-size: 14px;}#list td:nth-child(1), #list th:nth-child(1)
|
||||
{padding-left: 20px;width: 80%;}#list td:nth-child(2), #list th:nth-child(2)
|
||||
{text-align: right;padding: 0 20px;}#list td:nth-child(3), #list th:nth-child(3)
|
||||
{text-align: right;padding-right: 20px;}#list td:nth-child(1) svg {position:
|
||||
absolute;}#list td .goup, #list td .name {margin-left: 1.75em;word-break:
|
||||
break-all;overflow-wrap: break-word;white-space: pre-wrap;}footer {padding: 40px
|
||||
20px;font-size: 12px;text-align: center;}@media (max-width: 600px)
|
||||
{td:nth-child(1) {width: auto;}td:nth-child(2), th:nth-child(2) {display: none;}h1
|
||||
a {margin: 0;}#filter {max-width: 100px;}}@media (prefers-color-scheme: dark) {*
|
||||
{--color-secondary: #082437;--color-text: #dddddd;}
|
||||
body {background-color: #101010;}header {background-color:
|
||||
#151515;}#list tbody tr:hover {background-color: #252525;}a {color:
|
||||
#5796d1;text-decoration: none;}a:hover, h1 a:hover {color: #62b2fd;}#list tr
|
||||
{border-bottom: 1px dashed rgba(255, 255, 255, 0.12);}#filter {background-color:
|
||||
#151515;color: #ffffff;border: 1px solid #212121;}.meta {border-bottom: 1px solid
|
||||
#212121;}}
|
||||
</style>
|
||||
<title>
|
||||
Index:
|
||||
{{artifact_name}}
|
||||
</title>
|
||||
</head>
|
||||
|
||||
<body onload="initFilter()">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="0"
|
||||
width="0"
|
||||
style="position:absolute"
|
||||
><defs><g id="folder" fill-rule="nonzero" fill="none"><path
|
||||
d="M285.22 37.55h-142.6L110.9 0H31.7C14.25 0 0 16.9 0 37.55v75.1h316.92V75.1c0-20.65-14.26-37.55-31.7-37.55z"
|
||||
fill="#FFA000"
|
||||
/><path
|
||||
d="M285.22 36H31.7C14.25 36 0 50.28 0 67.74v158.7c0 17.47 14.26 31.75 31.7 31.75h253.5c17.44 0 31.7-14.3 31.7-31.75V67.75c0-17.47-14.26-31.75-31.7-31.75z"
|
||||
fill="#FFCA28"
|
||||
/></g><g
|
||||
id="file"
|
||||
stroke="#000"
|
||||
stroke-width="25"
|
||||
fill="#FFF"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path
|
||||
d="M13 24.12v274.76c0 6.16 5.87 11.12 13.17 11.12H239c7.3 0 13.17-4.96 13.17-11.12V136.15S132.6 13 128.37 13H26.17C18.87 13 13 17.96 13 24.12z"
|
||||
/><path
|
||||
d="M129.37 13 129 113.9c0 10.58 7.26 19.1 16.27 19.1H249L129.37 13z"
|
||||
/></g></defs></svg>
|
||||
|
||||
{{#> partial/header ~}}
|
||||
<title>Index: {{artifact_name}}</title>
|
||||
{{~/partial/header }}
|
||||
{{> partial/fileIcons }}
|
||||
<header>
|
||||
<a href="{{main_url}}" aria-label="Back to main page" style="height: 32px;">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 13.229 13.229"
|
||||
><g
|
||||
aria-label="AV"
|
||||
style="stroke-width:.264583"
|
||||
><path
|
||||
d="m12.381 2.878-2.698 7.557H8.73L6.031 2.878h.995L8.73 7.725q.17.466.286.879.116.402.19.772.074-.37.19-.783.117-.413.287-.889l1.693-4.826Z"
|
||||
style="fill:var(--color-text-light);fill-opacity:1"
|
||||
/><path
|
||||
d="m1.158 10.435 2.699-7.557h.952l2.699 7.557h-.995L4.81 5.588q-.169-.466-.285-.879-.117-.402-.19-.772-.075.37-.191.783-.117.412-.286.889l-1.694 4.826Z"
|
||||
style="fill:var(--color-text);fill-opacity:1;stroke-width:.264583"
|
||||
/></g></svg>
|
||||
</a>
|
||||
<h1>
|
||||
{{#each path_components}}<a href="{{this.url}}">{{this.name}}</a> /{{/each}}
|
||||
</h1>
|
||||
{{> partial/logoLink }}
|
||||
<h1>{{#each path_components}}<a href="{{this.url}}">{{this.name}}</a><span class="sep">/</span>{{/each}}</h1>
|
||||
</header>
|
||||
<main>
|
||||
<div class="meta">
|
||||
<div class="metadata">
|
||||
<div id="summary">
|
||||
<span class="meta-item"><b>{{n_dirs}}</b> director{{#if n_dirs != 1}}ies{{else}}y{{/if}}</span>
|
||||
<span class="meta-item"><b>{{n_files}}</b> file{{#if n_files != 1}}s{{/if}}</span>
|
||||
<span class="meta-item"><a
|
||||
href="{{run_url}}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>CI run</a></span>
|
||||
<span class="meta-item"><input
|
||||
type="text"
|
||||
placeholder="filter"
|
||||
id="filter"
|
||||
onkeyup="filter()"
|
||||
/></span>
|
||||
<span class="meta-item"><a href="{{run_url}}" target="_blank" rel="noopener noreferrer">CI run</a></span>
|
||||
<span class="meta-item"><input type="text" placeholder="filter" id="filter" onkeyup="filter()"/></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="listing">
|
||||
|
@ -116,10 +22,10 @@
|
|||
<th><a href="?C=N&O=A">Name</a> <a
|
||||
href="?C=N&O=D"
|
||||
> ↓ </a></th>
|
||||
<th>CRC32</th>
|
||||
<th><a href="?C=S&O=A">Size</a> <a
|
||||
href="?C=S&O=D"
|
||||
> ↓ </a></th>
|
||||
<th>CRC32</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
@ -130,40 +36,34 @@
|
|||
<td>—</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
{{ let vms = viewer_max_size }}
|
||||
{{#each entries}}
|
||||
<tr class="file">
|
||||
<td>
|
||||
<a href="{{this.name}}">
|
||||
<svg width="1.5em" height="1em" version="1.1" viewBox="0 0 317 259"><use xlink:href="{{#if this.is_dir}}#folder{{else}}#file{{/if}}"></use></svg>
|
||||
<span class="name">{{this.name}}</span>
|
||||
<a href="{{name}}{{#if !is_dir && size.0 <= vms }}?viewer=1{{/if}}">
|
||||
<svg width="1.5em" height="1em" version="1.1" viewBox="0 0 317 259"><use xlink:href="{{#if is_dir}}#folder{{else}}#file{{/if}}"></use></svg>
|
||||
<span class="name">{{name}}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>{{#if this.is_dir}}—{{else}}{{this.size}}{{/if}}</td>
|
||||
<td>{{#if this.is_dir}}—{{else}}{{this.crc32}}{{/if}}</td>
|
||||
<td>{{#if is_dir}}—{{else}}{{crc32}}{{/if}}</td>
|
||||
<td>{{#if is_dir}}—{{else}}{{size}}{{/if}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Served with
|
||||
<a
|
||||
href="https://code.thetadev.de/ThetaDev/artifactview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Artifactview</a>
|
||||
{{version}}
|
||||
</footer>
|
||||
{{#> partial/footer ~}}
|
||||
<script>
|
||||
|
||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
|
||||
|
||||
var filterEl=document.getElementById("filter");function initFilter(){if(!filterEl.value){var filterParam=new URL(window.location.href).searchParams.get("filter");if(filterParam){filterEl.value=filterParam}}filter()}function filter(){var q=filterEl.value.trim().toLowerCase();var elems=document.querySelectorAll("tr.file");elems.forEach(function(el){if(!q){el.style.display="";return}var nameEl=el.querySelector("td");var nameVal=nameEl.textContent.trim().toLowerCase();if(nameVal.indexOf(q)!==-1){el.style.display=""}else{el.style.display="none"}})}
|
||||
var filterEl = document.getElementById("filter");
|
||||
function initFilter() { if (!filterEl.value) { var filterParam = new URL(window.location.href).searchParams.get("filter"); if (filterParam) { filterEl.value = filterParam } } filter() }
|
||||
function filter() { var q = filterEl.value.trim().toLowerCase(); var elems = document.querySelectorAll("tr.file"); elems.forEach(function (el) { if (!q) { el.style.display = ""; return } var nameEl = el.querySelector("td"); var nameVal = nameEl.textContent.trim().toLowerCase(); if (nameVal.indexOf(q) !== -1) { el.style.display = "" } else { el.style.display = "none" } }) }
|
||||
document.addEventListener("DOMContentLoaded", initFilter);
|
||||
|
||||
// @license-end
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{~/partial/footer }}
|
||||
|
|
18
templates/partial/fileIcons.hbs
Normal file
|
@ -0,0 +1,18 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="0" width="0" style="position:absolute">
|
||||
<defs>
|
||||
<g id="folder" fill-rule="nonzero" fill="none">
|
||||
<path
|
||||
d="M285.22 37.55h-142.6L110.9 0H31.7C14.25 0 0 16.9 0 37.55v75.1h316.92V75.1c0-20.65-14.26-37.55-31.7-37.55z"
|
||||
fill="#FFA000" />
|
||||
<path
|
||||
d="M285.22 36H31.7C14.25 36 0 50.28 0 67.74v158.7c0 17.47 14.26 31.75 31.7 31.75h253.5c17.44 0 31.7-14.3 31.7-31.75V67.75c0-17.47-14.26-31.75-31.7-31.75z"
|
||||
fill="#FFCA28" />
|
||||
</g>
|
||||
<g id="file" stroke="#000" stroke-width="25" fill="#FFF" fill-rule="evenodd" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path
|
||||
d="M13 24.12v274.76c0 6.16 5.87 11.12 13.17 11.12H239c7.3 0 13.17-4.96 13.17-11.12V136.15S132.6 13 128.37 13H26.17C18.87 13 13 17.96 13 24.12z" />
|
||||
<path d="M129.37 13 129 113.9c0 10.58 7.26 19.1 16.27 19.1H249L129.37 13z" />
|
||||
</g>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 927 B |
13
templates/partial/footer.hbs
Normal file
|
@ -0,0 +1,13 @@
|
|||
<footer>
|
||||
Served with <a href="https://codeberg.org/ThetaDev/artifactview" target="_blank" rel="noopener noreferrer">Artifactview</a> {{ crate::app::VERSION }}
|
||||
<p class="light">
|
||||
<b>Disclaimer:</b> Artifactview does not host any websites, the data is fetched
|
||||
from the respective software forge and is only stored temporarily on this server.
|
||||
The publisher of this artifact, <a href="{{publisher.url}}" target="_blank" rel="noopener noreferrer">{{publisher.name}}</a>,
|
||||
is the only one responsible for the content.
|
||||
Most forges delete artifacts after 90 days.
|
||||
</p>
|
||||
</footer>
|
||||
{{> @partial-block }}
|
||||
</body>
|
||||
</html>
|
10
templates/partial/header.hbs
Normal file
|
@ -0,0 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="{{main_url}}{{ crate::app::STYLE_MAIN_PATH }}">
|
||||
{{> @partial-block }}
|
||||
</head>
|
||||
|
||||
<body>
|
1
templates/partial/logo.hbs
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="{{size}}" height="{{size}}" viewBox="0 0 13.229 13.229"><g aria-label="AV" style="stroke-width:.264583"><path d="m12.381 2.878-2.698 7.557H8.73L6.031 2.878h.995L8.73 7.725q.17.466.286.879.116.402.19.772.074-.37.19-.783.117-.413.287-.889l1.693-4.826Z" style="fill:var(--color-text-light);fill-opacity:1"/><path d="m1.158 10.435 2.699-7.557h.952l2.699 7.557h-.995L4.81 5.588q-.169-.466-.285-.879-.117-.402-.19-.772-.075.37-.191.783-.117.412-.286.889l-1.694 4.826Z" style="fill:var(--color-text);fill-opacity:1;stroke-width:.264583"/></g></svg>
|
After Width: | Height: | Size: 589 B |
3
templates/partial/logoLink.hbs
Normal file
|
@ -0,0 +1,3 @@
|
|||
<a href="{{main_url}}" aria-label="Back to main page" style="height: 32px;">
|
||||
{{> ./logo size="32" }}
|
||||
</a>
|
29
templates/preview.hbs
Normal file
|
@ -0,0 +1,29 @@
|
|||
{{#> partial/header ~}}
|
||||
<link rel="stylesheet" href="{{main_url}}{{ crate::app::STYLE_CONTENT_PATH }}">
|
||||
<title>{{filename}}</title>
|
||||
{{~/partial/header }}
|
||||
<header>
|
||||
{{> partial/logoLink }}
|
||||
<h1>{{#each path_components}}<a href="{{url}}">{{name}}</a><span class="sep">/</span>{{/each}}<span>{{filename}}</span></h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="metadata">
|
||||
<div id="summary">
|
||||
<div style="flex-grow: 1;">
|
||||
<span><b>{{lines}}</b> line{{#if lines != 1}}s{{/if}}</span>
|
||||
<span>{{size}}</span>
|
||||
<a href="{{run_url}}" target="_blank" rel="noopener noreferrer">CI run</a>
|
||||
</div>
|
||||
<div id="viewers">
|
||||
{{#each viewers}}<a {{#if selected}}class="selected"{{/if}} href="?viewer={{id}}">{{name}}</a>{{/each}}
|
||||
<a href="{{filename}}">Raw</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="viewer">
|
||||
{{{body}}}
|
||||
</div>
|
||||
</main>
|
||||
{{#> partial/footer ~}}
|
||||
{{~/partial/footer }}
|
|
@ -1,102 +1,17 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<style type="text/css">
|
||||
* {padding: 0;margin: 0;--color-secondary: #dedede;--color-text:
|
||||
#000;--color-text-light: #888;}body {font-family: sans-serif;text-rendering:
|
||||
optimizespeed;background-color: #f5f5f5;color: var(--color-text);}a {color:
|
||||
#006ed3;text-decoration: none;}a:hover {color: #319cff;}#summary, header {padding:
|
||||
0 20px;}header {display: flex;flex-direction: row;gap: 1em;padding-top:
|
||||
25px;padding-bottom: 15px;background-color: #f2f2f2;}header h1 {font-size:
|
||||
20px;font-weight: normal;white-space: nowrap;overflow-x: hidden;text-overflow:
|
||||
ellipsis;color: #999;}header h1 a {color: var(--color-text);margin: 0 4px;}footer
|
||||
a:hover, header h1 a:hover {text-decoration: underline;}header h1 a:first-child
|
||||
{margin: 0;}main {display: block;}.meta {font-size: 12px;font-family: Verdana,
|
||||
sans-serif;border-bottom: 1px solid #9c9c9c;padding-top: 10px;padding-bottom:
|
||||
10px;}.meta-item {margin-right: 1em;}#filter {padding: 4px;border: 1px solid
|
||||
#ccc;}#list {width: 100%;border-collapse: collapse;}#list tr {border-bottom: 1px
|
||||
dashed #dadada;}#list tbody tr:hover {background-color: #ffffec;}#list td, #list
|
||||
th {text-align: left;padding: 10px 0;}#list th {padding-top: 15px;padding-bottom:
|
||||
15px;font-size: 16px;white-space: nowrap;}#list th a {color:
|
||||
var(--color-text);}#list th svg {vertical-align: middle;}#list td {white-space:
|
||||
nowrap;font-size: 14px;}#list td:nth-child(1), #list th:nth-child(1)
|
||||
{padding-left: 20px;width: 80%;}#list td:nth-child(2), #list th:nth-child(2)
|
||||
{text-align: right;padding: 0 20px;}#list td:nth-child(3), #list th:nth-child(3)
|
||||
{text-align: right;padding-right: 20px;}#list td:nth-child(1) svg {position:
|
||||
absolute;}#list td .goup, #list td .name {margin-left: 1.75em;word-break:
|
||||
break-all;overflow-wrap: break-word;white-space: pre-wrap;}footer {padding: 40px
|
||||
20px;font-size: 12px;text-align: center;}p { margin: 16px 0; }.light{ color:
|
||||
var(--color-text-light); } @media (max-width: 600px)
|
||||
{td:nth-child(1) {width: auto;}td:nth-child(2), th:nth-child(2) {display: none;}h1
|
||||
a {margin: 0;}#filter {max-width: 100px;}}.expired {filter: grayscale(100%);}
|
||||
@media (prefers-color-scheme: dark) {*{--color-secondary: #082437;--color-text: #dddddd;}
|
||||
body {background-color: #101010;}header {background-color:
|
||||
#151515;}#list tbody tr:hover {background-color: #252525;}a {color:
|
||||
#5796d1;text-decoration: none;}a:hover, h1 a:hover {color: #62b2fd;}#list tr
|
||||
{border-bottom: 1px dashed rgba(255, 255, 255, 0.12);}#filter {background-color:
|
||||
#151515;color: #ffffff;border: 1px solid #212121;}.meta {border-bottom: 1px solid
|
||||
#212121;}}
|
||||
</style>
|
||||
<title>
|
||||
Artifacts:
|
||||
{{run_name}}
|
||||
</title>
|
||||
</head>
|
||||
|
||||
<body onload="initFilter()">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="0"
|
||||
width="0"
|
||||
style="position:absolute"
|
||||
><defs><g id="folder" fill-rule="nonzero" fill="none"><path
|
||||
d="M285.22 37.55h-142.6L110.9 0H31.7C14.25 0 0 16.9 0 37.55v75.1h316.92V75.1c0-20.65-14.26-37.55-31.7-37.55z"
|
||||
fill="#FFA000"
|
||||
/><path
|
||||
d="M285.22 36H31.7C14.25 36 0 50.28 0 67.74v158.7c0 17.47 14.26 31.75 31.7 31.75h253.5c17.44 0 31.7-14.3 31.7-31.75V67.75c0-17.47-14.26-31.75-31.7-31.75z"
|
||||
fill="#FFCA28"
|
||||
/></g></defs></svg>
|
||||
|
||||
{{#> partial/header ~}}
|
||||
<title>Artifacts: {{run_name}}</title>
|
||||
{{~/partial/header }}
|
||||
{{> partial/fileIcons }}
|
||||
<header>
|
||||
<a href="{{main_url}}" aria-label="Back to main page" style="height: 32px;">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 13.229 13.229"
|
||||
><g
|
||||
aria-label="AV"
|
||||
style="stroke-width:.264583"
|
||||
><path
|
||||
d="m12.381 2.878-2.698 7.557H8.73L6.031 2.878h.995L8.73 7.725q.17.466.286.879.116.402.19.772.074-.37.19-.783.117-.413.287-.889l1.693-4.826Z"
|
||||
style="fill:#888;fill-opacity:1"
|
||||
/><path
|
||||
d="m1.158 10.435 2.699-7.557h.952l2.699 7.557h-.995L4.81 5.588q-.169-.466-.285-.879-.117-.402-.19-.772-.075.37-.191.783-.117.412-.286.889l-1.694 4.826Z"
|
||||
style="fill:#ddd;fill-opacity:1;stroke-width:.264583"
|
||||
/></g></svg>
|
||||
</a>
|
||||
<h1>
|
||||
<a href="/">{{run_name}}</a>
|
||||
/
|
||||
</h1>
|
||||
{{> partial/logoLink }}
|
||||
<h1><a href="/?url={{run_url}}">{{run_name}}</a><span class="sep">/</span></h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="meta">
|
||||
<div class="metadata">
|
||||
<div id="summary">
|
||||
<span class="meta-item"><b>{{artifacts.len()}}</b> artifact{{#if artifacts.len() != 1}}s{{/if}}</span>
|
||||
<span class="meta-item"><a
|
||||
href="{{run_url}}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>CI run</a></span>
|
||||
<span class="meta-item"><input
|
||||
type="text"
|
||||
placeholder="filter"
|
||||
id="filter"
|
||||
onkeyup="filter()"
|
||||
/></span>
|
||||
<span class="meta-item"><a href="{{run_url}}" target="_blank" rel="noopener noreferrer">CI run</a></span>
|
||||
<span class="meta-item"><input type="text" placeholder="filter" id="filter" onkeyup="filter()"/></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="listing">
|
||||
|
@ -111,7 +26,7 @@
|
|||
<tbody>
|
||||
{{#each artifacts}}
|
||||
<tr class="file">
|
||||
{{#if this.expired}}
|
||||
{{#if expired}}
|
||||
<td>
|
||||
<svg
|
||||
class="expired"
|
||||
|
@ -120,27 +35,27 @@
|
|||
version="1.1"
|
||||
viewBox="0 0 317 259"
|
||||
><use xlink:href="#folder"></use></svg>
|
||||
<span class="name light">{{this.name}}</span>
|
||||
<span class="name light">{{name}}</span>
|
||||
</td>
|
||||
{{else}}
|
||||
<td>
|
||||
<a href="{{this.url}}">
|
||||
<a href="{{url}}">
|
||||
<svg
|
||||
width="1.5em"
|
||||
height="1em"
|
||||
version="1.1"
|
||||
viewBox="0 0 317 259"
|
||||
><use xlink:href="#folder"></use></svg>
|
||||
<span class="name">{{this.name}}</span>
|
||||
<span class="name">{{name}}</span>
|
||||
</a>
|
||||
</td>
|
||||
{{/if}}
|
||||
<td>{{this.size}}</td>
|
||||
<td>{{size}}</td>
|
||||
<td>
|
||||
{{#if this.expired}}
|
||||
{{#if expired}}
|
||||
—
|
||||
{{else}}
|
||||
<a href="{{this.download_url}}" rel="noopener noreferrer">Download</a>
|
||||
<a href="{{download_url}}" rel="noopener noreferrer">Download</a>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -150,32 +65,17 @@
|
|||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Served with
|
||||
<a
|
||||
href="https://code.thetadev.de/ThetaDev/artifactview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Artifactview</a>
|
||||
{{version}}
|
||||
<p class="light">
|
||||
<b>Disclaimer:</b> Artifactview does not host any websites, the data is fetched
|
||||
from the respective software forge and is only stored temporarily on this server.
|
||||
The publisher of this artifact,
|
||||
<a href="{{publisher.url}}" target="_blank" rel="noopener noreferrer">{{publisher.name}}</a>,
|
||||
is the only one responsible for the content.
|
||||
Most forges delete artifacts after 90 days.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
{{#> partial/footer ~}}
|
||||
<script>
|
||||
|
||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
|
||||
|
||||
var filterEl=document.getElementById("filter");function initFilter(){if(!filterEl.value){var filterParam=new URL(window.location.href).searchParams.get("filter");if(filterParam){filterEl.value=filterParam}}filter()}function filter(){var q=filterEl.value.trim().toLowerCase();var elems=document.querySelectorAll("tr.file");elems.forEach(function(el){if(!q){el.style.display="";return}var nameEl=el.querySelector("td");var nameVal=nameEl.textContent.trim().toLowerCase();if(nameVal.indexOf(q)!==-1){el.style.display=""}else{el.style.display="none"}})}
|
||||
var filterEl = document.getElementById("filter");
|
||||
function initFilter() { if (!filterEl.value) { var filterParam = new URL(window.location.href).searchParams.get("filter"); if (filterParam) { filterEl.value = filterParam } } filter() }
|
||||
function filter() { var q = filterEl.value.trim().toLowerCase(); var elems = document.querySelectorAll("tr.file"); elems.forEach(function (el) { if (!q) { el.style.display = ""; return } var nameEl = el.querySelector("td"); var nameVal = nameEl.textContent.trim().toLowerCase(); if (nameVal.indexOf(q) !== -1) { el.style.display = "" } else { el.style.display = "none" } }) }
|
||||
document.addEventListener("DOMContentLoaded", initFilter);
|
||||
|
||||
// @license-end
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{~/partial/footer }}
|
||||
|
|
108
templates/userscript.hbs
Normal file
|
@ -0,0 +1,108 @@
|
|||
// ==UserScript==
|
||||
// @name Artifactview
|
||||
// @version 0.1.0
|
||||
// @description Adds a "View artifact" button to GitHub/Gitea/Forgejo CI artifacts using the Artifactview instance {{{root_domain}}}
|
||||
// @author ThetaDev
|
||||
// @icon {{{main_url}}}/favicon.ico
|
||||
// @homepageURL {{{main_url}}}
|
||||
// @run-at document-idle
|
||||
// @grant none
|
||||
// @require https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=1108163
|
||||
{{~#each forge_urls}}
|
||||
// @match {{{this}}}
|
||||
{{~/each}}
|
||||
// ==/UserScript==
|
||||
|
||||
// Copyright (C) 2024 ThetaDev, MIT License
|
||||
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
const AV_HOST = "{{{root_domain}}}";
|
||||
const NO_HTTPS = {{{no_https}}};
|
||||
const ALIASES = {{ @json_pretty aliases }};
|
||||
|
||||
function encodeDomain(s, bias) {
|
||||
// Check if the character at the given position is in the middle of the string
|
||||
// and it is not followed by escape seq numbers or further escapable characters
|
||||
const isMidSingle = (str, pos) => {
|
||||
if (pos === 0) return false;
|
||||
const nc = str[pos + 1];
|
||||
return nc && !nc.match(/^[0-2\-\._]$/)
|
||||
};
|
||||
|
||||
// Escape dashes
|
||||
let buf = "";
|
||||
let last_pos = 0;
|
||||
while (true) {
|
||||
let pos = s.indexOf("-", last_pos);
|
||||
if (pos < 0) break;
|
||||
buf += s.substring(last_pos, pos);
|
||||
if (bias === "-" && isMidSingle(s, pos)) {
|
||||
buf += "-";
|
||||
} else {
|
||||
buf += "-1";
|
||||
}
|
||||
last_pos = pos + 1;
|
||||
}
|
||||
buf += s.substring(last_pos);
|
||||
|
||||
// Replace special chars [._]
|
||||
let buf2 = "";
|
||||
last_pos = 0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
if (buf[i] === "." || buf[i] === "_") {
|
||||
if (buf[i] === bias && isMidSingle(buf, i)) {
|
||||
buf2 += "-";
|
||||
} else if (buf[i] == ".") {
|
||||
buf2 += "-0";
|
||||
} else {
|
||||
buf2 += "-2";
|
||||
}
|
||||
} else {
|
||||
buf2 += buf[i];
|
||||
}
|
||||
}
|
||||
return buf2;
|
||||
}
|
||||
|
||||
function queryURL(host, user, repo, run, artifact) {
|
||||
const h = ALIASES[host] ?? encodeDomain(host, ".");
|
||||
return `http${NO_HTTPS ? "" : "s"}://${h}--${encodeDomain(user, "-")}--${encodeDomain(repo, "-")}--${run}-${artifact}.${AV_HOST}`;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
const m = url.pathname.match(/^\/([\w\-\.]+)\/([\w\-\.]+)\/actions\/runs\/(\d+)/);
|
||||
const ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" class="svg ui text black job-artifacts-icon octicon Button-visual"><path d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.022C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z"></path></svg>`;
|
||||
|
||||
if (m) {
|
||||
if (url.host === "github.com") {
|
||||
const init = () => document.querySelectorAll(`a[data-test-selector="download-artifact-button"]:not([data-has-view-button="true"])`).forEach((elm) => {
|
||||
const artifact = elm.getAttribute("href").match(/\d+$/)[0];
|
||||
elm.insertAdjacentHTML("beforebegin", `<a href="${queryURL(url.host, m[1], m[2], m[3], artifact)}" title="View artifact" target="_blank" rel="noopener noreferrer" class="Button Button--iconOnly Button--invisible Button--medium">${ICON}</a>`);
|
||||
elm.setAttribute("data-has-view-button", "true");
|
||||
});
|
||||
document.addEventListener("ghmo:container", init);
|
||||
init();
|
||||
} else {
|
||||
const rav = document.getElementById("repo-action-view");
|
||||
new MutationObserver((changes, observer) => {
|
||||
if (changes.find((c) => Array.from(c.addedNodes).find((n) => n.className === "job-artifacts"))) {
|
||||
document.querySelectorAll(".job-artifacts-item").forEach((elm, i) => {
|
||||
const delBtn = elm.querySelector(".job-artifacts-delete");
|
||||
if (delBtn) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.classList.add("tw-flex", "tw-gap-4", "tw-justify-end");
|
||||
wrapper.innerHTML = `<a href="${queryURL(url.host, m[1], m[2], m[3], i + 1)}" title="View artifact" target="_blank" rel="noopener noreferrer">${ICON}</a>`;
|
||||
elm.insertAdjacentElement("beforeend", wrapper);
|
||||
wrapper.appendChild(delBtn);
|
||||
}
|
||||
});
|
||||
observer.disconnect();
|
||||
}
|
||||
}).observe(rav, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
320
tests/testfiles/giteaWorkflowRun.json
Normal file
|
@ -0,0 +1,320 @@
|
|||
{
|
||||
"state": {
|
||||
"run": {
|
||||
"link": "/ThetaDev/test-actions/actions/runs/92",
|
||||
"title": "Update README.md",
|
||||
"status": "success",
|
||||
"canCancel": false,
|
||||
"canApprove": false,
|
||||
"canRerun": true,
|
||||
"canDeleteArtifact": true,
|
||||
"done": true,
|
||||
"jobs": [
|
||||
{
|
||||
"id": 377,
|
||||
"name": "Test",
|
||||
"status": "success",
|
||||
"canRerun": true,
|
||||
"duration": "2s"
|
||||
}
|
||||
],
|
||||
"commit": {
|
||||
"localeCommit": "Commit",
|
||||
"localePushedBy": "pushed by",
|
||||
"localeWorkflow": "Workflow",
|
||||
"shortSHA": "6185409d45",
|
||||
"link": "/ThetaDev/test-actions/commit/6185409d457e0a7833ee122811b138a950273229",
|
||||
"pusher": { "displayName": "ThetaDev", "link": "/ThetaDev" },
|
||||
"branch": { "name": "#3", "link": "/ThetaDev/test-actions/pulls/3" }
|
||||
}
|
||||
},
|
||||
"currentJob": {
|
||||
"title": "Test",
|
||||
"detail": "Success",
|
||||
"steps": [
|
||||
{
|
||||
"summary": "Set up job",
|
||||
"duration": "1s",
|
||||
"status": "success"
|
||||
},
|
||||
{ "summary": "Test", "duration": "0s", "status": "success" },
|
||||
{
|
||||
"summary": "Comment PR",
|
||||
"duration": "1s",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"summary": "Complete job",
|
||||
"duration": "0s",
|
||||
"status": "success"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"stepsLog": [
|
||||
{
|
||||
"step": 0,
|
||||
"cursor": 51,
|
||||
"lines": [
|
||||
{
|
||||
"index": 1,
|
||||
"message": "ocloud(version:v3.4.1) received task 431 of job 377, be triggered by event: pull_request",
|
||||
"timestamp": 1718902104.1911685
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"message": "workflow prepared",
|
||||
"timestamp": 1718902104.1916893
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"message": "evaluating expression 'success()'",
|
||||
"timestamp": 1718902104.1919434
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"message": "expression 'success()' evaluated to 'true'",
|
||||
"timestamp": 1718902104.1920443
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"message": "🚀 Start image=thetadev256/cimaster:latest",
|
||||
"timestamp": 1718902104.1920674
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"message": " 🐳 docker pull image=thetadev256/cimaster:latest platform= username= forcePull=true",
|
||||
"timestamp": 1718902104.203115
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"message": " 🐳 docker pull thetadev256/cimaster:latest",
|
||||
"timestamp": 1718902104.2031355
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"message": "pulling image 'docker.io/thetadev256/cimaster:latest' ()",
|
||||
"timestamp": 1718902104.2031558
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"message": "Pulling from thetadev256/cimaster :: latest",
|
||||
"timestamp": 1718902105.179988
|
||||
},
|
||||
{
|
||||
"index": 10,
|
||||
"message": "Digest: sha256:260659581e2900354877f31d5fec14db1c40999ad085a90a1a27c44b9cab8c48 :: ",
|
||||
"timestamp": 1718902105.1935806
|
||||
},
|
||||
{
|
||||
"index": 11,
|
||||
"message": "Status: Image is up to date for thetadev256/cimaster:latest :: ",
|
||||
"timestamp": 1718902105.1936345
|
||||
},
|
||||
{
|
||||
"index": 12,
|
||||
"message": "[{host 26303131f98bfa59ece2ca2dc1742040c8d125e22f1c07de603bd235bccf1b84 2024-04-02 20:48:57.065188715 +0000 UTC local host false {default map[] []} false false false {} false map[] map[] map[] [] map[]} {GITEA-ACTIONS-TASK-375_WORKFLOW-Build-and-push-cimaster-image_JOB-build-build-network 59ca72b83dbd990bda7af5e760fdb0e31010b855ca7e15df1a471fda8908aa47 2024-05-30 20:56:22.698163954 +0000 UTC local bridge false {default map[] [{172.24.0.0/16 172.24.0.1 map[]}]} false false false {} false map[] map[] map[] [] map[]} {none 627a84562dca9a81bd4a1fe570919035f1d608382d2b66b6b8559756d7aa2a6c 2024-04-02 20:48:57.050063369 +0000 UTC local null false {default map[] []} false false false {} false map[] map[] map[] [] map[]} {bridge 9b05952ef8d41f6a92bbc3c7f85c9fd5602e941b9e8e3cbcf84716de27d77aa9 2024-06-20 09:15:48.433095842 +0000 UTC local bridge false {default map[] [{172.17.0.0/16 172.17.0.1 map[]}]} false false false {} false map[] map[com.docker.network.bridge.default_bridge:true com.docker.network.bridge.enable_icc:true com.docker.network.bridge.enable_ip_masquerade:true com.docker.network.bridge.host_binding_ipv4:0.0.0.0 com.docker.network.bridge.name:docker0 com.docker.network.driver.mtu:1500] map[] [] map[]}]",
|
||||
"timestamp": 1718902105.203977
|
||||
},
|
||||
{
|
||||
"index": 13,
|
||||
"message": " 🐳 docker create image=thetadev256/cimaster:latest platform= entrypoint=[\"tail\" \"-f\" \"/dev/null\"] cmd=[] network=\"GITEA-ACTIONS-TASK-431_WORKFLOW-Rust-test_JOB-Test-Test-network\"",
|
||||
"timestamp": 1718902105.2669988
|
||||
},
|
||||
{
|
||||
"index": 14,
|
||||
"message": "Common container.Config ==\u003e \u0026{Hostname: Domainname: User: AttachStdin:false AttachStdout:false AttachStderr:false ExposedPorts:map[] Tty:false OpenStdin:false StdinOnce:false Env:[RUNNER_TOOL_CACHE=/opt/hostedtoolcache RUNNER_OS=Linux RUNNER_ARCH=ARM64 RUNNER_TEMP=/tmp LANG=C.UTF-8] Cmd:[] Healthcheck:\u003cnil\u003e ArgsEscaped:false Image:thetadev256/cimaster:latest Volumes:map[] WorkingDir:/workspace/ThetaDev/test-actions Entrypoint:[] NetworkDisabled:false MacAddress: OnBuild:[] Labels:map[] StopSignal: StopTimeout:\u003cnil\u003e Shell:[]}",
|
||||
"timestamp": 1718902105.2674763
|
||||
},
|
||||
{
|
||||
"index": 15,
|
||||
"message": "Common container.HostConfig ==\u003e \u0026{Binds:[] ContainerIDFile: LogConfig:{Type: Config:map[]} NetworkMode:GITEA-ACTIONS-TASK-431_WORKFLOW-Rust-test_JOB-Test-Test-network PortBindings:map[] RestartPolicy:{Name: MaximumRetryCount:0} AutoRemove:true VolumeDriver: VolumesFrom:[] ConsoleSize:[0 0] Annotations:map[] CapAdd:[] CapDrop:[] CgroupnsMode: DNS:[] DNSOptions:[] DNSSearch:[] ExtraHosts:[] GroupAdd:[] IpcMode: Cgroup: Links:[] OomScoreAdj:0 PidMode: Privileged:false PublishAllPorts:false ReadonlyRootfs:false SecurityOpt:[] StorageOpt:map[] Tmpfs:map[] UTSMode: UsernsMode: ShmSize:0 Sysctls:map[] Runtime: Isolation: Resources:{CPUShares:0 Memory:0 NanoCPUs:0 CgroupParent: BlkioWeight:0 BlkioWeightDevice:[] BlkioDeviceReadBps:[] BlkioDeviceWriteBps:[] BlkioDeviceReadIOps:[] BlkioDeviceWriteIOps:[] CPUPeriod:0 CPUQuota:0 CPURealtimePeriod:0 CPURealtimeRuntime:0 CpusetCpus: CpusetMems: Devices:[] DeviceCgroupRules:[] DeviceRequests:[] KernelMemory:0 KernelMemoryTCP:0 MemoryReservation:0 MemorySwap:0 MemorySwappiness:\u003cnil\u003e OomKillDisable:\u003cnil\u003e PidsLimit:\u003cnil\u003e Ulimits:[] CPUCount:0 CPUPercent:0 IOMaximumIOps:0 IOMaximumBandwidth:0} Mounts:[{Type:volume Source:GITEA-ACTIONS-TASK-431_WORKFLOW-Rust-test_JOB-Test-env Target:/var/run/act ReadOnly:false Consistency: BindOptions:\u003cnil\u003e VolumeOptions:\u003cnil\u003e TmpfsOptions:\u003cnil\u003e ClusterOptions:\u003cnil\u003e} {Type:volume Source:GITEA-ACTIONS-TASK-431_WORKFLOW-Rust-test_JOB-Test Target:/workspace/ThetaDev/test-actions ReadOnly:false Consistency: BindOptions:\u003cnil\u003e VolumeOptions:\u003cnil\u003e TmpfsOptions:\u003cnil\u003e ClusterOptions:\u003cnil\u003e} {Type:volume Source:act-toolcache Target:/opt/hostedtoolcache ReadOnly:false Consistency: BindOptions:\u003cnil\u003e VolumeOptions:\u003cnil\u003e TmpfsOptions:\u003cnil\u003e ClusterOptions:\u003cnil\u003e}] MaskedPaths:[] ReadonlyPaths:[] Init:\u003cnil\u003e}",
|
||||
"timestamp": 1718902105.2731254
|
||||
},
|
||||
{
|
||||
"index": 16,
|
||||
"message": "input.NetworkAliases ==\u003e [Test]",
|
||||
"timestamp": 1718902105.2733588
|
||||
},
|
||||
{
|
||||
"index": 17,
|
||||
"message": "Created container name=GITEA-ACTIONS-TASK-431_WORKFLOW-Rust-test_JOB-Test id=953c4349622d86e68aa9b0d25b341f0ff4b36d87d5ffbae957d9efd72c3f6d64 from image thetadev256/cimaster:latest (platform: )",
|
||||
"timestamp": 1718902105.3252785
|
||||
},
|
||||
{
|
||||
"index": 18,
|
||||
"message": "ENV ==\u003e [RUNNER_TOOL_CACHE=/opt/hostedtoolcache RUNNER_OS=Linux RUNNER_ARCH=ARM64 RUNNER_TEMP=/tmp LANG=C.UTF-8]",
|
||||
"timestamp": 1718902105.3253257
|
||||
},
|
||||
{
|
||||
"index": 19,
|
||||
"message": " 🐳 docker run image=thetadev256/cimaster:latest platform= entrypoint=[\"tail\" \"-f\" \"/dev/null\"] cmd=[] network=\"GITEA-ACTIONS-TASK-431_WORKFLOW-Rust-test_JOB-Test-Test-network\"",
|
||||
"timestamp": 1718902105.3253412
|
||||
},
|
||||
{
|
||||
"index": 20,
|
||||
"message": "Starting container: 953c4349622d86e68aa9b0d25b341f0ff4b36d87d5ffbae957d9efd72c3f6d64",
|
||||
"timestamp": 1718902105.3253546
|
||||
},
|
||||
{
|
||||
"index": 21,
|
||||
"message": "Started container: 953c4349622d86e68aa9b0d25b341f0ff4b36d87d5ffbae957d9efd72c3f6d64",
|
||||
"timestamp": 1718902105.5463858
|
||||
},
|
||||
{
|
||||
"index": 22,
|
||||
"message": " 🐳 docker exec cmd=[chown -R 1000:1000 /workspace/ThetaDev/test-actions] user=0 workdir=",
|
||||
"timestamp": 1718902105.6031785
|
||||
},
|
||||
{
|
||||
"index": 23,
|
||||
"message": "Exec command '[chown -R 1000:1000 /workspace/ThetaDev/test-actions]'",
|
||||
"timestamp": 1718902105.6032245
|
||||
},
|
||||
{
|
||||
"index": 24,
|
||||
"message": "Working directory '/workspace/ThetaDev/test-actions'",
|
||||
"timestamp": 1718902105.6032348
|
||||
},
|
||||
{
|
||||
"index": 25,
|
||||
"message": "Writing entry to tarball workflow/event.json len:8795",
|
||||
"timestamp": 1718902105.6331673
|
||||
},
|
||||
{
|
||||
"index": 26,
|
||||
"message": "Writing entry to tarball workflow/envs.txt len:0",
|
||||
"timestamp": 1718902105.6332214
|
||||
},
|
||||
{
|
||||
"index": 27,
|
||||
"message": "Extracting content to '/var/run/act/'",
|
||||
"timestamp": 1718902105.6332443
|
||||
},
|
||||
{
|
||||
"index": 28,
|
||||
"message": " ☁ git clone 'https://code.thetadev.de/actions/comment-pull-request' # ref=v1",
|
||||
"timestamp": 1718902105.6492677
|
||||
},
|
||||
{
|
||||
"index": 29,
|
||||
"message": " cloning https://code.thetadev.de/actions/comment-pull-request to /data/.cache/act/https---code.thetadev.de-actions-comment-pull-request@v1",
|
||||
"timestamp": 1718902105.6493008
|
||||
},
|
||||
{
|
||||
"index": 30,
|
||||
"message": "Cloned https://code.thetadev.de/actions/comment-pull-request to /data/.cache/act/https---code.thetadev.de-actions-comment-pull-request@v1",
|
||||
"timestamp": 1718902105.6817598
|
||||
},
|
||||
{
|
||||
"index": 31,
|
||||
"message": "Checked out v1",
|
||||
"timestamp": 1718902105.7090926
|
||||
},
|
||||
{
|
||||
"index": 32,
|
||||
"message": "Read action \u0026{Comment Pull Request Comments a pull request with the provided message map[GITHUB_TOKEN:{Github token of the repository (automatically created by Github) false ${{ github.token }}} comment_tag:{A tag on your comment that will be used to identify a comment in case of replacement. false } create_if_not_exists:{Whether a comment should be created even if comment_tag is not found. false true} filePath:{Path of the file that should be commented false } message:{Message that should be printed in the pull request false } pr_number:{Manual pull request number false } reactions:{You can set some reactions on your comments through the `reactions` input. false } recreate:{Delete and recreate the comment instead of updating it false false}] map[] {node20 map[] act/index.js always() always() [] []} {blue message-circle}} from 'Unknown'",
|
||||
"timestamp": 1718902105.709367
|
||||
},
|
||||
{
|
||||
"index": 33,
|
||||
"message": "setupEnv =\u003e map[ACT:true ACTIONS_CACHE_URL:http://192.168.96.3:44491/ ACTIONS_RESULTS_URL:https://code.thetadev.de ACTIONS_RUNTIME_TOKEN:*** ACTIONS_RUNTIME_URL:https://code.thetadev.de/api/actions_pipeline/ CI:true GITEA_ACTIONS:true GITEA_ACTIONS_RUNNER_VERSION:v3.4.1 GITHUB_ACTION:0 GITHUB_ACTIONS:true GITHUB_ACTION_PATH: GITHUB_ACTION_REF: GITHUB_ACTION_REPOSITORY: GITHUB_ACTOR:ThetaDev GITHUB_API_URL:https://code.thetadev.de/api/v1 GITHUB_BASE_REF:main GITHUB_EVENT_NAME:pull_request GITHUB_EVENT_PATH:/var/run/act/workflow/event.json GITHUB_GRAPHQL_URL: GITHUB_HEAD_REF:thetadev-patch-2 GITHUB_JOB:Test GITHUB_REF:refs/pull/3/head GITHUB_REF_NAME:3 GITHUB_REF_TYPE: GITHUB_REPOSITORY:ThetaDev/test-actions GITHUB_REPOSITORY_OWNER:ThetaDev GITHUB_RETENTION_DAYS: GITHUB_RUN_ID:292 GITHUB_RUN_NUMBER:92 GITHUB_SERVER_URL:https://code.thetadev.de GITHUB_SHA:6185409d457e0a7833ee122811b138a950273229 GITHUB_TOKEN:*** GITHUB_WORKFLOW:Rust test GITHUB_WORKSPACE:/workspace/ThetaDev/test-actions ImageOS:cimasterlatest JOB_CONTAINER_NAME:GITEA-ACTIONS-TASK-431_WORKFLOW-Rust-test_JOB-Test RUNNER_PERFLOG:/dev/null RUNNER_TRACKING_ID:]",
|
||||
"timestamp": 1718902105.7244232
|
||||
},
|
||||
{
|
||||
"index": 34,
|
||||
"message": "evaluating expression ''",
|
||||
"timestamp": 1718902105.7316134
|
||||
},
|
||||
{
|
||||
"index": 35,
|
||||
"message": "expression '' evaluated to 'true'",
|
||||
"timestamp": 1718902105.7316737
|
||||
},
|
||||
{
|
||||
"index": 36,
|
||||
"message": "⭐ Run Main Test",
|
||||
"timestamp": 1718902105.7316883
|
||||
},
|
||||
{
|
||||
"index": 37,
|
||||
"message": "Writing entry to tarball workflow/outputcmd.txt len:0",
|
||||
"timestamp": 1718902105.7317095
|
||||
},
|
||||
{
|
||||
"index": 38,
|
||||
"message": "Writing entry to tarball workflow/statecmd.txt len:0",
|
||||
"timestamp": 1718902105.7317333
|
||||
},
|
||||
{
|
||||
"index": 39,
|
||||
"message": "Writing entry to tarball workflow/pathcmd.txt len:0",
|
||||
"timestamp": 1718902105.7317476
|
||||
},
|
||||
{
|
||||
"index": 40,
|
||||
"message": "Writing entry to tarball workflow/envs.txt len:0",
|
||||
"timestamp": 1718902105.7317617
|
||||
},
|
||||
{
|
||||
"index": 41,
|
||||
"message": "Writing entry to tarball workflow/SUMMARY.md len:0",
|
||||
"timestamp": 1718902105.7317736
|
||||
},
|
||||
{
|
||||
"index": 42,
|
||||
"message": "Extracting content to '/var/run/act'",
|
||||
"timestamp": 1718902105.731786
|
||||
},
|
||||
{
|
||||
"index": 43,
|
||||
"message": "expression 'echo \"${{ secrets.FORGEJO_CI_TOKEN }}\"\\n' rewritten to 'format('echo \"{0}\"\\n', secrets.FORGEJO_CI_TOKEN)'",
|
||||
"timestamp": 1718902105.754891
|
||||
},
|
||||
{
|
||||
"index": 44,
|
||||
"message": "evaluating expression 'format('echo \"{0}\"\\n', secrets.FORGEJO_CI_TOKEN)'",
|
||||
"timestamp": 1718902105.7549253
|
||||
},
|
||||
{
|
||||
"index": 45,
|
||||
"message": "expression 'format('echo \"{0}\"\\n', secrets.FORGEJO_CI_TOKEN)' evaluated to '%!t(string=echo \"***\"\\n)'",
|
||||
"timestamp": 1718902105.7549586
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"message": "Wrote command \\n\\necho \"***\"\\n\\n\\n to 'workflow/0'",
|
||||
"timestamp": 1718902105.754978
|
||||
},
|
||||
{
|
||||
"index": 47,
|
||||
"message": "Writing entry to tarball workflow/0 len:50",
|
||||
"timestamp": 1718902105.755002
|
||||
},
|
||||
{
|
||||
"index": 48,
|
||||
"message": "Extracting content to '/var/run/act'",
|
||||
"timestamp": 1718902105.755024
|
||||
},
|
||||
{
|
||||
"index": 49,
|
||||
"message": " 🐳 docker exec cmd=[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/0] user= workdir=",
|
||||
"timestamp": 1718902105.7571557
|
||||
},
|
||||
{
|
||||
"index": 50,
|
||||
"message": "Exec command '[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/0]'",
|
||||
"timestamp": 1718902105.7571852
|
||||
},
|
||||
{
|
||||
"index": 51,
|
||||
"message": "Working directory '/workspace/ThetaDev/test-actions'",
|
||||
"timestamp": 1718902105.7572272
|
||||
}
|
||||
],
|
||||
"started": 1718902104
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
220
tests/testfiles/githubWorkflowRun.json
Normal file
|
@ -0,0 +1,220 @@
|
|||
{
|
||||
"id": 9598566319,
|
||||
"name": "db-tests",
|
||||
"node_id": "WFR_kwLOBFIx288AAAACPB5_rw",
|
||||
"head_branch": "fix-ui-tab",
|
||||
"head_sha": "7ae95457156ea964402747ae263d5a2a7de48883",
|
||||
"path": ".github/workflows/pull-db-tests.yml",
|
||||
"display_title": "WIP: Fix tab performance",
|
||||
"run_number": 20434,
|
||||
"event": "pull_request",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
"workflow_id": 56971384,
|
||||
"check_suite_id": 25125296548,
|
||||
"check_suite_node_id": "CS_kwDOBFIx288AAAAF2ZWZpA",
|
||||
"url": "https://api.github.com/repos/go-gitea/gitea/actions/runs/9598566319",
|
||||
"html_url": "https://github.com/go-gitea/gitea/actions/runs/9598566319",
|
||||
"pull_requests": [],
|
||||
"created_at": "2024-06-20T13:41:06Z",
|
||||
"updated_at": "2024-06-20T14:10:02Z",
|
||||
"actor": {
|
||||
"login": "wxiaoguang",
|
||||
"id": 2114189,
|
||||
"node_id": "MDQ6VXNlcjIxMTQxODk=",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/2114189?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/wxiaoguang",
|
||||
"html_url": "https://github.com/wxiaoguang",
|
||||
"followers_url": "https://api.github.com/users/wxiaoguang/followers",
|
||||
"following_url": "https://api.github.com/users/wxiaoguang/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/wxiaoguang/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/wxiaoguang/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/wxiaoguang/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/wxiaoguang/orgs",
|
||||
"repos_url": "https://api.github.com/users/wxiaoguang/repos",
|
||||
"events_url": "https://api.github.com/users/wxiaoguang/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/wxiaoguang/received_events",
|
||||
"type": "User",
|
||||
"site_admin": false
|
||||
},
|
||||
"run_attempt": 1,
|
||||
"referenced_workflows": [
|
||||
{
|
||||
"path": "go-gitea/gitea/.github/workflows/files-changed.yml@d8d6749d313098583fc1d527ce8a4aafb81ca12d",
|
||||
"sha": "d8d6749d313098583fc1d527ce8a4aafb81ca12d",
|
||||
"ref": "refs/pull/31437/merge"
|
||||
}
|
||||
],
|
||||
"run_started_at": "2024-06-20T13:41:06Z",
|
||||
"triggering_actor": {
|
||||
"login": "wxiaoguang",
|
||||
"id": 2114189,
|
||||
"node_id": "MDQ6VXNlcjIxMTQxODk=",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/2114189?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/wxiaoguang",
|
||||
"html_url": "https://github.com/wxiaoguang",
|
||||
"followers_url": "https://api.github.com/users/wxiaoguang/followers",
|
||||
"following_url": "https://api.github.com/users/wxiaoguang/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/wxiaoguang/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/wxiaoguang/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/wxiaoguang/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/wxiaoguang/orgs",
|
||||
"repos_url": "https://api.github.com/users/wxiaoguang/repos",
|
||||
"events_url": "https://api.github.com/users/wxiaoguang/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/wxiaoguang/received_events",
|
||||
"type": "User",
|
||||
"site_admin": false
|
||||
},
|
||||
"jobs_url": "https://api.github.com/repos/go-gitea/gitea/actions/runs/9598566319/jobs",
|
||||
"logs_url": "https://api.github.com/repos/go-gitea/gitea/actions/runs/9598566319/logs",
|
||||
"check_suite_url": "https://api.github.com/repos/go-gitea/gitea/check-suites/25125296548",
|
||||
"artifacts_url": "https://api.github.com/repos/go-gitea/gitea/actions/runs/9598566319/artifacts",
|
||||
"cancel_url": "https://api.github.com/repos/go-gitea/gitea/actions/runs/9598566319/cancel",
|
||||
"rerun_url": "https://api.github.com/repos/go-gitea/gitea/actions/runs/9598566319/rerun",
|
||||
"previous_attempt_url": null,
|
||||
"workflow_url": "https://api.github.com/repos/go-gitea/gitea/actions/workflows/56971384",
|
||||
"head_commit": {
|
||||
"id": "7ae95457156ea964402747ae263d5a2a7de48883",
|
||||
"tree_id": "edb45bf6711cdcff1ee0347e330a0bd5b89996ec",
|
||||
"message": "fix",
|
||||
"timestamp": "2024-06-20T13:40:55Z",
|
||||
"author": { "name": "wxiaoguang", "email": "wxiaoguang@gmail.com" },
|
||||
"committer": { "name": "wxiaoguang", "email": "wxiaoguang@gmail.com" }
|
||||
},
|
||||
"repository": {
|
||||
"id": 72495579,
|
||||
"node_id": "MDEwOlJlcG9zaXRvcnk3MjQ5NTU3OQ==",
|
||||
"name": "gitea",
|
||||
"full_name": "go-gitea/gitea",
|
||||
"private": false,
|
||||
"owner": {
|
||||
"login": "go-gitea",
|
||||
"id": 12724356,
|
||||
"node_id": "MDEyOk9yZ2FuaXphdGlvbjEyNzI0MzU2",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/12724356?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/go-gitea",
|
||||
"html_url": "https://github.com/go-gitea",
|
||||
"followers_url": "https://api.github.com/users/go-gitea/followers",
|
||||
"following_url": "https://api.github.com/users/go-gitea/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/go-gitea/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/go-gitea/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/go-gitea/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/go-gitea/orgs",
|
||||
"repos_url": "https://api.github.com/users/go-gitea/repos",
|
||||
"events_url": "https://api.github.com/users/go-gitea/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/go-gitea/received_events",
|
||||
"type": "Organization",
|
||||
"site_admin": false
|
||||
},
|
||||
"html_url": "https://github.com/go-gitea/gitea",
|
||||
"description": "Git with a cup of tea! Painless self-hosted all-in-one software development service, including Git hosting, code review, team collaboration, package registry and CI/CD",
|
||||
"fork": false,
|
||||
"url": "https://api.github.com/repos/go-gitea/gitea",
|
||||
"forks_url": "https://api.github.com/repos/go-gitea/gitea/forks",
|
||||
"keys_url": "https://api.github.com/repos/go-gitea/gitea/keys{/key_id}",
|
||||
"collaborators_url": "https://api.github.com/repos/go-gitea/gitea/collaborators{/collaborator}",
|
||||
"teams_url": "https://api.github.com/repos/go-gitea/gitea/teams",
|
||||
"hooks_url": "https://api.github.com/repos/go-gitea/gitea/hooks",
|
||||
"issue_events_url": "https://api.github.com/repos/go-gitea/gitea/issues/events{/number}",
|
||||
"events_url": "https://api.github.com/repos/go-gitea/gitea/events",
|
||||
"assignees_url": "https://api.github.com/repos/go-gitea/gitea/assignees{/user}",
|
||||
"branches_url": "https://api.github.com/repos/go-gitea/gitea/branches{/branch}",
|
||||
"tags_url": "https://api.github.com/repos/go-gitea/gitea/tags",
|
||||
"blobs_url": "https://api.github.com/repos/go-gitea/gitea/git/blobs{/sha}",
|
||||
"git_tags_url": "https://api.github.com/repos/go-gitea/gitea/git/tags{/sha}",
|
||||
"git_refs_url": "https://api.github.com/repos/go-gitea/gitea/git/refs{/sha}",
|
||||
"trees_url": "https://api.github.com/repos/go-gitea/gitea/git/trees{/sha}",
|
||||
"statuses_url": "https://api.github.com/repos/go-gitea/gitea/statuses/{sha}",
|
||||
"languages_url": "https://api.github.com/repos/go-gitea/gitea/languages",
|
||||
"stargazers_url": "https://api.github.com/repos/go-gitea/gitea/stargazers",
|
||||
"contributors_url": "https://api.github.com/repos/go-gitea/gitea/contributors",
|
||||
"subscribers_url": "https://api.github.com/repos/go-gitea/gitea/subscribers",
|
||||
"subscription_url": "https://api.github.com/repos/go-gitea/gitea/subscription",
|
||||
"commits_url": "https://api.github.com/repos/go-gitea/gitea/commits{/sha}",
|
||||
"git_commits_url": "https://api.github.com/repos/go-gitea/gitea/git/commits{/sha}",
|
||||
"comments_url": "https://api.github.com/repos/go-gitea/gitea/comments{/number}",
|
||||
"issue_comment_url": "https://api.github.com/repos/go-gitea/gitea/issues/comments{/number}",
|
||||
"contents_url": "https://api.github.com/repos/go-gitea/gitea/contents/{+path}",
|
||||
"compare_url": "https://api.github.com/repos/go-gitea/gitea/compare/{base}...{head}",
|
||||
"merges_url": "https://api.github.com/repos/go-gitea/gitea/merges",
|
||||
"archive_url": "https://api.github.com/repos/go-gitea/gitea/{archive_format}{/ref}",
|
||||
"downloads_url": "https://api.github.com/repos/go-gitea/gitea/downloads",
|
||||
"issues_url": "https://api.github.com/repos/go-gitea/gitea/issues{/number}",
|
||||
"pulls_url": "https://api.github.com/repos/go-gitea/gitea/pulls{/number}",
|
||||
"milestones_url": "https://api.github.com/repos/go-gitea/gitea/milestones{/number}",
|
||||
"notifications_url": "https://api.github.com/repos/go-gitea/gitea/notifications{?since,all,participating}",
|
||||
"labels_url": "https://api.github.com/repos/go-gitea/gitea/labels{/name}",
|
||||
"releases_url": "https://api.github.com/repos/go-gitea/gitea/releases{/id}",
|
||||
"deployments_url": "https://api.github.com/repos/go-gitea/gitea/deployments"
|
||||
},
|
||||
"head_repository": {
|
||||
"id": 398521154,
|
||||
"node_id": "MDEwOlJlcG9zaXRvcnkzOTg1MjExNTQ=",
|
||||
"name": "gitea",
|
||||
"full_name": "wxiaoguang/gitea",
|
||||
"private": false,
|
||||
"owner": {
|
||||
"login": "wxiaoguang",
|
||||
"id": 2114189,
|
||||
"node_id": "MDQ6VXNlcjIxMTQxODk=",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/2114189?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/wxiaoguang",
|
||||
"html_url": "https://github.com/wxiaoguang",
|
||||
"followers_url": "https://api.github.com/users/wxiaoguang/followers",
|
||||
"following_url": "https://api.github.com/users/wxiaoguang/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/wxiaoguang/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/wxiaoguang/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/wxiaoguang/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/wxiaoguang/orgs",
|
||||
"repos_url": "https://api.github.com/users/wxiaoguang/repos",
|
||||
"events_url": "https://api.github.com/users/wxiaoguang/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/wxiaoguang/received_events",
|
||||
"type": "User",
|
||||
"site_admin": false
|
||||
},
|
||||
"html_url": "https://github.com/wxiaoguang/gitea",
|
||||
"description": "Git with a cup of tea, painless self-hosted git service",
|
||||
"fork": true,
|
||||
"url": "https://api.github.com/repos/wxiaoguang/gitea",
|
||||
"forks_url": "https://api.github.com/repos/wxiaoguang/gitea/forks",
|
||||
"keys_url": "https://api.github.com/repos/wxiaoguang/gitea/keys{/key_id}",
|
||||
"collaborators_url": "https://api.github.com/repos/wxiaoguang/gitea/collaborators{/collaborator}",
|
||||
"teams_url": "https://api.github.com/repos/wxiaoguang/gitea/teams",
|
||||
"hooks_url": "https://api.github.com/repos/wxiaoguang/gitea/hooks",
|
||||
"issue_events_url": "https://api.github.com/repos/wxiaoguang/gitea/issues/events{/number}",
|
||||
"events_url": "https://api.github.com/repos/wxiaoguang/gitea/events",
|
||||
"assignees_url": "https://api.github.com/repos/wxiaoguang/gitea/assignees{/user}",
|
||||
"branches_url": "https://api.github.com/repos/wxiaoguang/gitea/branches{/branch}",
|
||||
"tags_url": "https://api.github.com/repos/wxiaoguang/gitea/tags",
|
||||
"blobs_url": "https://api.github.com/repos/wxiaoguang/gitea/git/blobs{/sha}",
|
||||
"git_tags_url": "https://api.github.com/repos/wxiaoguang/gitea/git/tags{/sha}",
|
||||
"git_refs_url": "https://api.github.com/repos/wxiaoguang/gitea/git/refs{/sha}",
|
||||
"trees_url": "https://api.github.com/repos/wxiaoguang/gitea/git/trees{/sha}",
|
||||
"statuses_url": "https://api.github.com/repos/wxiaoguang/gitea/statuses/{sha}",
|
||||
"languages_url": "https://api.github.com/repos/wxiaoguang/gitea/languages",
|
||||
"stargazers_url": "https://api.github.com/repos/wxiaoguang/gitea/stargazers",
|
||||
"contributors_url": "https://api.github.com/repos/wxiaoguang/gitea/contributors",
|
||||
"subscribers_url": "https://api.github.com/repos/wxiaoguang/gitea/subscribers",
|
||||
"subscription_url": "https://api.github.com/repos/wxiaoguang/gitea/subscription",
|
||||
"commits_url": "https://api.github.com/repos/wxiaoguang/gitea/commits{/sha}",
|
||||
"git_commits_url": "https://api.github.com/repos/wxiaoguang/gitea/git/commits{/sha}",
|
||||
"comments_url": "https://api.github.com/repos/wxiaoguang/gitea/comments{/number}",
|
||||
"issue_comment_url": "https://api.github.com/repos/wxiaoguang/gitea/issues/comments{/number}",
|
||||
"contents_url": "https://api.github.com/repos/wxiaoguang/gitea/contents/{+path}",
|
||||
"compare_url": "https://api.github.com/repos/wxiaoguang/gitea/compare/{base}...{head}",
|
||||
"merges_url": "https://api.github.com/repos/wxiaoguang/gitea/merges",
|
||||
"archive_url": "https://api.github.com/repos/wxiaoguang/gitea/{archive_format}{/ref}",
|
||||
"downloads_url": "https://api.github.com/repos/wxiaoguang/gitea/downloads",
|
||||
"issues_url": "https://api.github.com/repos/wxiaoguang/gitea/issues{/number}",
|
||||
"pulls_url": "https://api.github.com/repos/wxiaoguang/gitea/pulls{/number}",
|
||||
"milestones_url": "https://api.github.com/repos/wxiaoguang/gitea/milestones{/number}",
|
||||
"notifications_url": "https://api.github.com/repos/wxiaoguang/gitea/notifications{?since,all,participating}",
|
||||
"labels_url": "https://api.github.com/repos/wxiaoguang/gitea/labels{/name}",
|
||||
"releases_url": "https://api.github.com/repos/wxiaoguang/gitea/releases{/id}",
|
||||
"deployments_url": "https://api.github.com/repos/wxiaoguang/gitea/deployments"
|
||||
}
|
||||
}
|
40
tests/testfiles/junit/hello.junit.xml
Normal file
|
@ -0,0 +1,40 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites name="nextest-run" tests="2" failures="1" errors="0" uuid="80dd57da-f066-4d37-9b3c-d1d98c5789bd" timestamp="2024-06-11T20:32:12.532+02:00" time="0.007">
|
||||
<testsuite name="lib1" tests="2" disabled="0" errors="0" failures="1">
|
||||
<testcase name="tests::it_works" classname="lib1" timestamp="2024-06-11T20:32:12.532+02:00" time="0.003">
|
||||
<system-out>
|
||||
running 1 test
|
||||
test tests::it_works ... ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s
|
||||
|
||||
</system-out>
|
||||
<system-err></system-err>
|
||||
</testcase>
|
||||
<testcase name="tests::pippi_langstrumpf" classname="lib1" timestamp="2024-06-11T20:32:12.532+02:00" time="0.003">
|
||||
<failure type="test failure">thread 'tests::pippi_langstrumpf' panicked at src/lib.rs:18:9:
|
||||
assertion `left == right` failed
|
||||
left: 7
|
||||
right: 9
|
||||
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace</failure>
|
||||
<system-out>
|
||||
running 1 test
|
||||
test tests::pippi_langstrumpf ... FAILED
|
||||
|
||||
failures:
|
||||
|
||||
failures:
|
||||
tests::pippi_langstrumpf
|
||||
|
||||
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s
|
||||
|
||||
</system-out>
|
||||
<system-err>thread 'tests::pippi_langstrumpf' panicked at src/lib.rs:18:9:
|
||||
assertion `left == right` failed
|
||||
left: 7
|
||||
right: 9
|
||||
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
|
||||
</system-err>
|
||||
</testcase>
|
||||
</testsuite>
|
||||
</testsuites>
|
1859
tests/testfiles/junit/retry.junit.xml
Normal file
1447
tests/testfiles/junit/simple.junit.xml
Normal file
1
tests/testfiles/sites/.well-known/test.txt
Normal file
|
@ -0,0 +1 @@
|
|||
This file should NOT be served for security reasons
|
10
tests/testfiles/sites/200.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>SPA test</title>
|
||||
<link rel='stylesheet' type='text/css' href='./style.css'>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello SPA</h1>
|
||||
</body>
|
||||
</html>
|
10
tests/testfiles/sites/404.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Error 404</title>
|
||||
<link rel='stylesheet' type='text/css' href='./style.css'>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Not found</h1>
|
||||
</body>
|
||||
</html>
|
17
tests/testfiles/sites/example.rs
Normal file
|
@ -0,0 +1,17 @@
|
|||
use serde::{Serialize, Deserialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct Point {
|
||||
x: i32,
|
||||
y: i32,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let point = Point { x: 1, y: 2 };
|
||||
|
||||
let serialized = serde_json::to_string(&point).unwrap();
|
||||
println!("serialized = {}", serialized);
|
||||
|
||||
let deserialized: Point = serde_json::from_str(&serialized).unwrap();
|
||||
println!("deserialized = {:?}", deserialized);
|
||||
}
|
10
tests/testfiles/sites/index.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Artifactview test</title>
|
||||
<link rel='stylesheet' type='text/css' href='./style.css'>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello World</h1>
|
||||
</body>
|
||||
</html>
|
37
tests/testfiles/sites/make_zip.sh
Executable file
|
@ -0,0 +1,37 @@
|
|||
#!/bin/bash
|
||||
# Create artifacts for testing
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TARGET="../sites_data"
|
||||
# http://codeberg-org--thetadev--artifactview-test--1-1.localhost:3000
|
||||
T_VIEW="codeberg-org--thetadev--artifactview-test--1-1"
|
||||
# http://codeberg-org--thetadev--artifactview-test--1-2.localhost:3000
|
||||
T_404="codeberg-org--thetadev--artifactview-test--1-2"
|
||||
# http://codeberg-org--thetadev--artifactview-test--1-3.localhost:3000
|
||||
T_SPA="codeberg-org--thetadev--artifactview-test--1-3"
|
||||
|
||||
mkdir -p $TARGET
|
||||
rm -f $TARGET/*
|
||||
|
||||
zip --no-dir-entries $TARGET/$T_SPA index.html style.css 200.html
|
||||
zip --no-dir-entries $TARGET/$T_404 index.html style.css 404.html
|
||||
|
||||
zip --no-dir-entries -r $TARGET/$T_VIEW robots.txt .well-known
|
||||
zip --no-dir-entries --junk-paths $TARGET/$T_VIEW example.rs ../../../README.md
|
||||
|
||||
(
|
||||
cd ..
|
||||
zip --no-dir-entries -r sites_data/$T_VIEW junit sites/index.html sites/style.css
|
||||
)
|
||||
|
||||
printf "404" > $TARGET/$T_404.name
|
||||
printf "spa" > $TARGET/$T_SPA.name
|
||||
printf "view" > $TARGET/$T_VIEW.name
|
||||
|
||||
if [ -n "$LTST" ]; then
|
||||
mkdir -p /tmp/artifactview
|
||||
cp $TARGET/* /tmp/artifactview
|
||||
echo "copied artifacts for local testing"
|
||||
fi
|
1
tests/testfiles/sites/robots.txt
Normal file
|
@ -0,0 +1 @@
|
|||
This file should NOT be served for security reasons
|
5
tests/testfiles/sites/style.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
body {
|
||||
background-color: black;
|
||||
color: white;
|
||||
font-family: monospace;
|
||||
}
|
454
tests/tests.rs
Normal file
|
@ -0,0 +1,454 @@
|
|||
use std::{
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use axum_test::{TestRequest, TestResponse, TestServer};
|
||||
use headers::HeaderMapExt;
|
||||
use http::{header, HeaderName, HeaderValue, StatusCode};
|
||||
use once_cell::sync::Lazy;
|
||||
use path_macro::path;
|
||||
use rstest::{fixture, rstest};
|
||||
|
||||
use artifactview::{App, AppState, Config, ConfigData};
|
||||
use scraper::{selectable::Selectable, ElementRef, Html, Selector};
|
||||
use temp_testdir::TempDir;
|
||||
|
||||
static TESTFILES: Lazy<PathBuf> =
|
||||
Lazy::new(|| path!(env!("CARGO_MANIFEST_DIR") / "tests" / "testfiles"));
|
||||
|
||||
static SITEDIR: Lazy<PathBuf> = Lazy::new(|| {
|
||||
let sitedir = path!(*TESTFILES / "sites_data");
|
||||
if !sitedir.is_dir() {
|
||||
std::process::Command::new(path!(*TESTFILES / "sites" / "make_zip.sh"))
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
sitedir
|
||||
});
|
||||
|
||||
static CACHEDIR: Lazy<TempDir> = Lazy::new(|| {
|
||||
let td = TempDir::default();
|
||||
setup_cache_dir(&td);
|
||||
td
|
||||
});
|
||||
|
||||
const S1: &str = "codeberg-org--thetadev--artifactview-test--1-1";
|
||||
const S2: &str = "codeberg-org--thetadev--artifactview-test--1-2";
|
||||
const S3: &str = "codeberg-org--thetadev--artifactview-test--1-3";
|
||||
|
||||
fn setup_cache_dir(dir: &Path) {
|
||||
for entry in std::fs::read_dir(SITEDIR.as_path()).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
if entry.file_type().unwrap().is_file() {
|
||||
std::fs::copy(entry.path(), path!(dir / entry.file_name())).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TestAv {
|
||||
server: TestServer,
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn server() -> TestAv {
|
||||
let router = App::router(AppState::from_cfg(
|
||||
Config::from_data(ConfigData {
|
||||
cache_dir: CACHEDIR.to_path_buf(),
|
||||
no_https: true,
|
||||
real_ip_header: Some("x-forwarded-for".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
));
|
||||
|
||||
let mut server = TestServer::new(router).unwrap();
|
||||
|
||||
server.add_header(
|
||||
HeaderName::from_static("x-forwarded-for"),
|
||||
HeaderValue::from_static("127.0.0.1"),
|
||||
);
|
||||
|
||||
TestAv { server }
|
||||
}
|
||||
|
||||
impl TestAv {
|
||||
fn get(&self, subdomain: &str, path: &str) -> TestRequest {
|
||||
self.server.get(path).add_header(
|
||||
header::HOST,
|
||||
if subdomain.is_empty() {
|
||||
HeaderValue::from_static("localhost:3000")
|
||||
} else {
|
||||
HeaderValue::from_str(&format!("{subdomain}.localhost:3000")).unwrap()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_html(&self, subdomain: &str, path: &str) -> Html {
|
||||
let resp = self.get(subdomain, path).await;
|
||||
resp.assert_status_ok();
|
||||
assert_eq!(resp.header(header::CONTENT_TYPE), "text/html");
|
||||
scraper::Html::parse_document(&resp.text())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FileEntry {
|
||||
name: String,
|
||||
size: String,
|
||||
crc32: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Link {
|
||||
text: String,
|
||||
href: String,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
fn text_from_elm(elm: &ElementRef<'_>) -> String {
|
||||
elm.text()
|
||||
.fold(String::new(), |acc, s| acc + s)
|
||||
.trim()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn parse_link(elm: &ElementRef<'_>) -> Link {
|
||||
assert_eq!(elm.value().name(), "a");
|
||||
Link {
|
||||
text: text_from_elm(elm),
|
||||
href: elm.attr("href").expect("href").to_owned(),
|
||||
selected: elm
|
||||
.value()
|
||||
.has_class("selected", scraper::CaseSensitivity::CaseSensitive),
|
||||
}
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_cache_immutable(resp: &TestResponse) {
|
||||
assert_eq!(
|
||||
resp.header(header::CACHE_CONTROL),
|
||||
"max-age=31536000,public,immutable"
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn homepage(server: TestAv) {
|
||||
let doc = server.get_html("", "/").await;
|
||||
let elm = doc
|
||||
.select(&Selector::parse("title").unwrap())
|
||||
.next()
|
||||
.unwrap();
|
||||
assert_eq!(text_from_elm(&elm), "Artifactview");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(S1, "/example.rs", true, "example.rs", "text/x-rust")]
|
||||
#[case(S1, "/sites/", true, "index.html", "text/html")]
|
||||
#[case(S1, "/foo.txt", false, "", "text/html")]
|
||||
#[case(S1, "/.well-known/test.txt", false, "", "text/html")]
|
||||
// 404 fallback
|
||||
#[case(S2, "/foo.txt", false, "404.html", "text/html")]
|
||||
// SPA
|
||||
#[case(S3, "/", true, "index.html", "text/html")]
|
||||
#[case(S3, "/foo.txt", true, "200.html", "text/html")]
|
||||
#[tokio::test]
|
||||
async fn get_file(
|
||||
server: TestAv,
|
||||
#[case] sd: &str,
|
||||
#[case] path: &str,
|
||||
#[case] found: bool,
|
||||
#[case] exp_path: &str,
|
||||
#[case] mime: &str,
|
||||
) {
|
||||
let resp = server.get(sd, path).await;
|
||||
assert_eq!(resp.header(header::CONTENT_TYPE), mime);
|
||||
|
||||
if found {
|
||||
resp.assert_status_ok();
|
||||
assert_cache_immutable(&resp);
|
||||
} else {
|
||||
resp.assert_status_not_found();
|
||||
}
|
||||
|
||||
if !exp_path.is_empty() {
|
||||
let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / exp_path)).unwrap();
|
||||
assert_eq!(resp.text(), expect);
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn robots_txt(server: TestAv) {
|
||||
let resp1 = server.get("", "/robots.txt").await;
|
||||
let resp2 = server.get(S1, "/robots.txt").await;
|
||||
|
||||
assert!(resp1.text().contains("\nUser-agent: *\nDisallow: /\n"));
|
||||
assert!(resp2.text().contains("\nUser-agent: *\nDisallow: /\n"));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn stylesheet(server: TestAv) {
|
||||
let html = server.get_html(S1, "/README.md?viewer=1").await;
|
||||
for path in html
|
||||
.select(&Selector::parse("link[rel=\"stylesheet\"]").unwrap())
|
||||
.map(|elm| {
|
||||
elm.attr("href")
|
||||
.expect("href")
|
||||
.strip_prefix("http://localhost:3000")
|
||||
.expect("localhost url")
|
||||
})
|
||||
{
|
||||
let resp = server.get("", path).await;
|
||||
resp.assert_status_ok();
|
||||
assert_eq!(resp.header(header::CONTENT_TYPE), "text/css");
|
||||
assert_cache_immutable(&resp);
|
||||
|
||||
// Remove running number from stylesheet path
|
||||
let fname = path
|
||||
.strip_prefix('/')
|
||||
.unwrap()
|
||||
.strip_suffix(".css")
|
||||
.unwrap()
|
||||
.trim_end_matches(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']);
|
||||
let expect = std::fs::read_to_string(path!(
|
||||
env!("CARGO_MANIFEST_DIR") / "resources" / format!("{fname}.css")
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(resp.text(), expect);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_listing(doc: &Html) -> Vec<FileEntry> {
|
||||
let sel_col = Selector::parse("td").unwrap();
|
||||
doc.select(&Selector::parse(".file").unwrap())
|
||||
.map(|elm| {
|
||||
let icn = elm
|
||||
.select(&Selector::parse("svg > use").unwrap())
|
||||
.next()
|
||||
.expect("icon");
|
||||
let (k, icn_id) = icn.value().attrs().next().expect("icon id");
|
||||
assert_eq!(k, "href");
|
||||
assert!(matches!(icn_id, "#file" | "#folder"));
|
||||
|
||||
let mut parts = elm.select(&sel_col).map(|elm| text_from_elm(&elm));
|
||||
|
||||
let name = parts.next().expect("name");
|
||||
let folder = icn_id == "#folder";
|
||||
assert_eq!(name.ends_with('/'), folder);
|
||||
|
||||
FileEntry {
|
||||
name,
|
||||
crc32: parts.next().expect("crc32"),
|
||||
size: parts.next().expect("size"),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("/", &[
|
||||
".well-known/",
|
||||
"junit/",
|
||||
"sites/",
|
||||
"README.md",
|
||||
"example.rs",
|
||||
"robots.txt"
|
||||
])]
|
||||
#[case("/?C=N&O=D", &[
|
||||
"sites/",
|
||||
"junit/",
|
||||
".well-known/",
|
||||
"robots.txt",
|
||||
"example.rs",
|
||||
"README.md",
|
||||
])]
|
||||
#[tokio::test]
|
||||
async fn listing(server: TestAv, #[case] path: &str, #[case] expect: &[&str]) {
|
||||
let doc = server.get_html(S1, path).await;
|
||||
let files = parse_listing(&doc);
|
||||
let file_names = files.iter().map(|f| f.name.to_owned()).collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(file_names, expect);
|
||||
|
||||
let example_rs = files
|
||||
.iter()
|
||||
.find(|f| f.name == "example.rs")
|
||||
.expect("example.rs");
|
||||
assert_eq!(example_rs.size, "406 B");
|
||||
assert_eq!(example_rs.crc32, "2013120c");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
// JUnit
|
||||
#[case("/junit/hello.junit.xml?viewer=1", "junit", &[("junit", "JUnit"), ("code", "Code")])]
|
||||
#[case("/junit/hello.junit.xml?viewer=junit", "junit", &[("junit", "JUnit"), ("code", "Code")])]
|
||||
#[case("/junit/hello.junit.xml?viewer=code", "code", &[("junit", "JUnit"), ("code", "Code")])]
|
||||
#[case("/junit/hello.junit.xml?viewer=md", "", &[])]
|
||||
#[case("/example.rs?viewer=1", "code", &[("code", "Code")])]
|
||||
#[case("/README.md?viewer=1", "md", &[("md", "Markdown"), ("code", "Code")])]
|
||||
#[tokio::test]
|
||||
async fn viewer(
|
||||
server: TestAv,
|
||||
#[case] path: &str,
|
||||
#[case] vid: &str,
|
||||
#[case] exp_viewers: &[(&str, &str)],
|
||||
) {
|
||||
if vid.is_empty() {
|
||||
// Raw file
|
||||
let resp = server.get(S1, path).await;
|
||||
resp.assert_status_ok();
|
||||
assert_eq!(resp.header(header::CONTENT_TYPE), "text/xml");
|
||||
assert_cache_immutable(&resp);
|
||||
} else {
|
||||
let doc = server.get_html(S1, path).await;
|
||||
let viewers = doc
|
||||
.select(&Selector::parse("#viewers a").unwrap())
|
||||
.map(|elm| parse_link(&elm))
|
||||
.filter(|ln| ln.text != "Raw")
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let viewer_names = viewers
|
||||
.iter()
|
||||
.map(|v| {
|
||||
(
|
||||
v.href.strip_prefix("?viewer=").expect("link prefix"),
|
||||
v.text.as_str(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(viewer_names, exp_viewers);
|
||||
|
||||
// Selected viewer
|
||||
let mut sel_iter = viewers.iter().filter(|ln| ln.selected);
|
||||
let selected = sel_iter.next().expect("selected");
|
||||
assert_eq!(selected.href, format!("?viewer={vid}"));
|
||||
assert!(sel_iter.next().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn if_modified(server: TestAv) {
|
||||
let resp = server.get(S1, "/README.md").await;
|
||||
let lastmod = SystemTime::from(
|
||||
resp.headers()
|
||||
.typed_get::<headers::LastModified>()
|
||||
.expect("last modified"),
|
||||
);
|
||||
let bef_lastmod = lastmod - Duration::from_secs(1);
|
||||
|
||||
// if-modified-since
|
||||
let resp = server
|
||||
.get(S1, "/README.md")
|
||||
.add_header(
|
||||
header::IF_MODIFIED_SINCE,
|
||||
HeaderValue::from_str(&httpdate::HttpDate::from(lastmod).to_string()).unwrap(),
|
||||
)
|
||||
.await;
|
||||
resp.assert_status(StatusCode::NOT_MODIFIED);
|
||||
|
||||
let resp = server
|
||||
.get(S1, "/README.md")
|
||||
.add_header(
|
||||
header::IF_MODIFIED_SINCE,
|
||||
HeaderValue::from_str(&httpdate::HttpDate::from(bef_lastmod).to_string()).unwrap(),
|
||||
)
|
||||
.await;
|
||||
resp.assert_status_ok();
|
||||
|
||||
// if-unmodified-since
|
||||
let resp = server
|
||||
.get(S1, "/README.md")
|
||||
.add_header(
|
||||
header::IF_UNMODIFIED_SINCE,
|
||||
HeaderValue::from_str(&httpdate::HttpDate::from(lastmod).to_string()).unwrap(),
|
||||
)
|
||||
.await;
|
||||
resp.assert_status_ok();
|
||||
|
||||
let resp = server
|
||||
.get(S1, "/README.md")
|
||||
.add_header(
|
||||
header::IF_UNMODIFIED_SINCE,
|
||||
HeaderValue::from_str(&httpdate::HttpDate::from(bef_lastmod).to_string()).unwrap(),
|
||||
)
|
||||
.await;
|
||||
resp.assert_status(StatusCode::PRECONDITION_FAILED);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn range_request(server: TestAv) {
|
||||
let resp = server
|
||||
.get(S1, "/example.rs")
|
||||
.add_header(header::RANGE, HeaderValue::from_static("bytes=10-99"))
|
||||
.await;
|
||||
resp.assert_status(StatusCode::PARTIAL_CONTENT);
|
||||
assert_cache_immutable(&resp);
|
||||
assert_eq!(resp.header(header::CONTENT_TYPE), "text/x-rust");
|
||||
assert_eq!(resp.header(header::CONTENT_LENGTH), "90");
|
||||
assert_eq!(resp.header(header::CONTENT_RANGE), "bytes 10-99/406");
|
||||
|
||||
let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / "example.rs")).unwrap();
|
||||
assert_eq!(resp.text(), &expect[10..100]);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn range_request_start(server: TestAv) {
|
||||
let resp = server
|
||||
.get(S1, "/example.rs")
|
||||
.add_header(header::RANGE, HeaderValue::from_static("bytes=100-"))
|
||||
.await;
|
||||
resp.assert_status(StatusCode::PARTIAL_CONTENT);
|
||||
assert_cache_immutable(&resp);
|
||||
assert_eq!(resp.header(header::CONTENT_TYPE), "text/x-rust");
|
||||
assert_eq!(resp.header(header::CONTENT_LENGTH), "306");
|
||||
assert_eq!(resp.header(header::CONTENT_RANGE), "bytes 100-405/406");
|
||||
|
||||
let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / "example.rs")).unwrap();
|
||||
assert_eq!(resp.text(), &expect[100..]);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn compressed(server: TestAv) {
|
||||
let resp = server
|
||||
.get(S1, "/example.rs")
|
||||
.add_header(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip"))
|
||||
.await;
|
||||
let bts = resp.into_bytes().to_vec();
|
||||
let mut gz = flate2::read::GzDecoder::new(&bts[..]);
|
||||
|
||||
let mut buf = String::new();
|
||||
gz.read_to_string(&mut buf).unwrap();
|
||||
|
||||
let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / "example.rs")).unwrap();
|
||||
assert_eq!(buf, expect);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn userscript(server: TestAv) {
|
||||
let resp = server.get("", "/artifactview.user.js").await;
|
||||
resp.assert_status_ok();
|
||||
let script = resp.text();
|
||||
assert!(script.starts_with("// ==UserScript==\n"));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
/// Redirect user to the directory path if index.html or directory listing is served
|
||||
async fn index_redirect(server: TestAv) {
|
||||
let resp = server.get(S1, "/sites").await;
|
||||
resp.assert_status(StatusCode::PERMANENT_REDIRECT);
|
||||
assert_eq!(resp.header(header::LOCATION), "/sites/");
|
||||
|
||||
let resp = server.get(S1, "/junit").await;
|
||||
resp.assert_status(StatusCode::PERMANENT_REDIRECT);
|
||||
assert_eq!(resp.header(header::LOCATION), "/junit/");
|
||||
}
|