diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..d93ec572 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,142 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +permissions: + contents: read + pull-requests: write +concurrency: + group: playwright-${{ github.ref }} + cancel-in-progress: true +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Set up Go 1.x + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + check-latest: true + cache: true + + - name: Use Node.js LTS + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: lts/* + + - name: Build Argus (web + binary) + run: make build + + - name: Start Argus test server + run: make playwright-tests-setup + env: + ARGUS_SERVICE_LATEST_VERSION_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARGUS_SERVICE_LATEST_VERSION_REQUIRE_DOCKER_REGISTRY_HUB_AUTH_USERNAME: ${{ vars.DOCKER_HUB_USERNAME_R }} + ARGUS_SERVICE_LATEST_VERSION_REQUIRE_DOCKER_REGISTRY_HUB_AUTH_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN_R }} + ARGUS_SERVICE_LATEST_VERSION_REQUIRE_DOCKER_REGISTRY_GHCR_AUTH_TOKEN: ${{ secrets.DOCKER_GHCR_TOKEN_R }} + ARGUS_SERVICE_LATEST_VERSION_REQUIRE_DOCKER_REGISTRY_QUAY_AUTH_TOKEN: ${{ secrets.DOCKER_QUAY_TOKEN_R }} + + - name: Install Playwright dependencies + working-directory: web/ui/react-app + run: npm ci + + - name: Type-check Playwright tests + working-directory: web/ui/react-app + run: npm run typecheck:e2e + + - name: Install Playwright Browsers + working-directory: web/ui/react-app + run: npx playwright install --with-deps chromium firefox webkit + + - name: Run Playwright tests + run: make playwright-tests + + - name: Upload test results (screenshots & traces) + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: ${{ !cancelled() }} + with: + name: test-results + path: web/ui/react-app/test-results/ + retention-days: 30 + + - name: Upload Playwright HTML report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: web/ui/react-app/playwright-report/ + retention-days: 30 + + - name: Comment test results on PR + continue-on-error: true + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const fs = require('fs'); + const { owner, repo } = context.repo; + const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${context.runId}`; + + let body; + try { + const { stats } = JSON.parse(fs.readFileSync('web/ui/react-app/results.json', 'utf8')); + const icon = stats.unexpected > 0 ? '❌' : '✅'; + const rows = [ + ['✅ Passed', stats.expected], + ['❌ Failed', stats.unexpected], + ['🔄 Flaky', stats.flaky], + ['⏭️ Skipped', stats.skipped], + ].map(([label, n]) => `| ${label} | ${n} |`).join('\n'); + body = [ + `## ${icon} Playwright Test Results`, + '', + '| | Count |', + '|---|---|', + rows, + '', + `Duration: ${(stats.duration / 1000).toFixed(1)}s`, + '', + `[View screenshots & full report](${runUrl})`, + ].join('\n'); + } catch { + body = `## Playwright Test Results\n\nResults unavailable — [view run](${runUrl}).`; + } + + const marker = ''; + body = `${marker}\n${body}`; + const issue_number = context.payload.pull_request.number; + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number, + per_page: 100, + }); + const existing = comments.find( + (c) => c.user?.type === 'Bot' && c.body?.includes(marker), + ); + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } + + + - name: Stop Argus test server + if: ${{ !cancelled() }} + run: make playwright-tests-teardown diff --git a/.gitignore b/.gitignore index be689235..8731ed7d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# MacOS +.DS_Store + # Binaries for programs and plugins *.exe *.exe~ @@ -5,11 +8,9 @@ *.so *.dylib -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - # Dependency directories vendor/ +node_modules/ # Output of `make build` /argus @@ -20,12 +21,9 @@ vendor/ # Output of `make build-all` /.build/ +# Output of the go coverage tool +*.out + # Ignore personal files config.yml -argus.db - -# React -node_modules - -# MacOS -.DS_Store \ No newline at end of file +argus.db \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 28d2b2fb..3f753cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -540,10 +540,10 @@ New mascot! Thanks [@rexapex](https://github.com/rexapex) ### Features * **command:** apply version var templating to args ([5bf93b7](https://github.com/release-argus/Argus/commit/5bf93b7930978a4c65fe6fb3aaf57f4b9f0b7d56)) -* **config:** `active` var to disable a service ([#88](https://github.com/release-argus/argus/issues/88)) ([af756f4](https://github.com/release-argus/Argus/commit/af756f458cfbe9f379a96e338376d20c1702976c)) -* **config:** `comment` var for services ([#90](https://github.com/release-argus/argus/issues/90)) ([a6b68eb](https://github.com/release-argus/Argus/commit/a6b68eb72495f8ff450feba1b9cb2f47fe1523db)) -* **ui:** icons can be links - `icon_link_to` ([#92](https://github.com/release-argus/argus/issues/92)) ([8c3a9af](https://github.com/release-argus/Argus/commit/8c3a9af5c38a8ea3ce16fdf1c62ab0a730a16359)) -* **webhook:** add `gitlab` type ([#95](https://github.com/release-argus/argus/issues/95)) ([5a8ab55](https://github.com/release-argus/Argus/commit/5a8ab551e672f08e6a82fdcbd3e0d3bc8f498c0d)) +* **config:** `active` var to disable a service ([#88](https://github.com/release-argus/Argus/issues/88)) ([af756f4](https://github.com/release-argus/Argus/commit/af756f458cfbe9f379a96e338376d20c1702976c)) +* **config:** `comment` var for services ([#90](https://github.com/release-argus/Argus/issues/90)) ([a6b68eb](https://github.com/release-argus/Argus/commit/a6b68eb72495f8ff450feba1b9cb2f47fe1523db)) +* **ui:** icons can be links - `icon_link_to` ([#92](https://github.com/release-argus/Argus/issues/92)) ([8c3a9af](https://github.com/release-argus/Argus/commit/8c3a9af5c38a8ea3ce16fdf1c62ab0a730a16359)) +* **webhook:** add `gitlab` type ([#95](https://github.com/release-argus/Argus/issues/95)) ([5a8ab55](https://github.com/release-argus/Argus/commit/5a8ab551e672f08e6a82fdcbd3e0d3bc8f498c0d)) * **webhook:** apply version var templating to custom headers + url ([e31f51b](https://github.com/release-argus/Argus/commit/e31f51bc9877a40101e74c9cb930f2adf9be564d)) diff --git a/Makefile b/Makefile index 67eefa37..c91be925 100644 --- a/Makefile +++ b/Makefile @@ -45,3 +45,71 @@ build: web common-build .PHONY: build-all build-all: web-build compress-web build-darwin build-freebsd build-linux build-openbsd build-windows + +PLAYWRIGHT_DIR = $(UI_PATH)/react-app +PLAYWRIGHT_TESTS_DIR = $(PLAYWRIGHT_DIR)/tests +PLAYWRIGHT_CONFIG_FILE = $(PLAYWRIGHT_TESTS_DIR)/playwright-test-config.yml +PLAYWRIGHT_DB_FILE = $(PLAYWRIGHT_TESTS_DIR)/playwright-tests.db +PLAYWRIGHT_PID_FILE = $(PLAYWRIGHT_TESTS_DIR)/playwright-argus.pid +PLAYWRIGHT_LOG_FILE = $(PLAYWRIGHT_TESTS_DIR)/playwright-test.log +# Port the Makefile-managed Argus server listens on. +PLAYWRIGHT_PORT ?= 8080 +# URL the Playwright tests run against; defaults to the managed server. +BASE_URL ?= http://localhost:$(PLAYWRIGHT_PORT) + +# wait_for_argus,: poll the healthcheck endpoint until it answers (max 60s). +define wait_for_argus +i=0; until curl -sf $(1)/api/v1/healthcheck >/dev/null; do \ + i=$$((i+1)); \ + if [ $$i -ge 30 ]; then \ + echo "Argus not reachable at $(1) after 60s" >&2; \ + if [ -f $(PLAYWRIGHT_LOG_FILE) ]; then cat $(PLAYWRIGHT_LOG_FILE) >&2; fi; \ + exit 1; \ + fi; \ + sleep 2; \ +done +endef + +.PHONY: playwright-tests-setup +playwright-tests-setup: + @if [ -n "$(FRESH)" ]; then \ + $(MAKE) playwright-tests-teardown; \ + rm -f ./$(OUTPUT_BINARY); \ + elif [ -f $(PLAYWRIGHT_PID_FILE) ]; then \ + kill $$(cat $(PLAYWRIGHT_PID_FILE)) 2>/dev/null || true; \ + rm -f $(PLAYWRIGHT_PID_FILE); \ + fi + cp config.yml.example $(PLAYWRIGHT_CONFIG_FILE) + @if [ ! -x ./$(OUTPUT_BINARY) ] || [ -n "$(FORCE)" ]; then $(MAKE) build; fi + ./$(OUTPUT_BINARY) \ + -config.file $(PLAYWRIGHT_CONFIG_FILE) \ + -data.database-file $(PLAYWRIGHT_DB_FILE) \ + -web.listen-port $(PLAYWRIGHT_PORT) \ + -log.level DEBUG \ + > $(PLAYWRIGHT_LOG_FILE) 2>&1 & echo $$! > $(PLAYWRIGHT_PID_FILE) + @$(call wait_for_argus,http://localhost:$(PLAYWRIGHT_PORT)) + +.PHONY: playwright-tests-teardown +playwright-tests-teardown: + @if [ -f $(PLAYWRIGHT_PID_FILE) ]; then \ + kill $$(cat $(PLAYWRIGHT_PID_FILE)) 2>/dev/null || true; \ + rm -f $(PLAYWRIGHT_PID_FILE); \ + else \ + echo "No playwright Argus instance found running ($(PLAYWRIGHT_PID_FILE) missing)"; \ + fi + rm -f $(PLAYWRIGHT_CONFIG_FILE) $(PLAYWRIGHT_DB_FILE)* $(PLAYWRIGHT_LOG_FILE) + +.PHONY: playwright-tests +playwright-tests: + @echo "Waiting for Argus to be ready at $(BASE_URL)..." + @$(call wait_for_argus,$(BASE_URL)) + cd $(PLAYWRIGHT_DIR) && \ + BASE_URL=$(BASE_URL) \ + PWTEST_CHILD_PROCESS_TIMEOUT=30000 \ + npx playwright test $(if $(HEADED),--headed,) + +.PHONY: playwright-full +playwright-full: playwright-tests-setup + @$(MAKE) playwright-tests; status=$$?; \ + $(MAKE) playwright-tests-teardown; \ + exit $$status diff --git a/README.md b/README.md index 77af6a19..a4c46206 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,16 @@ Keeping an eye on releases. -[![GitHub](https://img.shields.io/github/license/release-argus/argus)](https://github.com/release-argus/Argus/blob/master/LICENSE) +[![GitHub](https://img.shields.io/github/license/release-argus/Argus)](https://github.com/release-argus/Argus/blob/master/LICENSE) [![Go Report Card](https://goreportcard.com/badge/github.com/release-argus/Argus)](https://goreportcard.com/report/github.com/release-argus/Argus) -[![GitHub go.mod Go version (subdirectory of monorepo)](https://img.shields.io/github/go-mod/go-version/release-argus/argus?filename=go.mod)](https://go.dev/dl/) -[![GitHub package.json dependency version (subfolder of monorepo)](https://img.shields.io/github/package-json/dependency-version/release-argus/argus/react?filename=web%2Fui%2Freact-app%2Fpackage.json)](https://reactjs.org/) -[![Codecov](https://img.shields.io/codecov/c/github/release-argus/argus)](https://app.codecov.io/gh/release-argus/Argus) +[![GitHub go.mod Go version (subdirectory of monorepo)](https://img.shields.io/github/go-mod/go-version/release-argus/Argus?filename=go.mod)](https://go.dev/dl/) +[![GitHub package.json dependency version (subfolder of monorepo)](https://img.shields.io/github/package-json/dependency-version/release-argus/Argus/react?filename=web%2Fui%2Freact-app%2Fpackage.json)](https://reactjs.org/) +[![Codecov](https://img.shields.io/codecov/c/github/release-argus/Argus)](https://app.codecov.io/gh/release-argus/Argus)
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/release-argus/Argus/build-binary.yml)](https://github.com/release-argus/Argus/actions/workflows/build-binary.yml) -[![GitHub release (latest by date)](https://img.shields.io/github/v/release/release-argus/argus)](https://github.com/release-argus/Argus/releases) -[![GitHub all releases](https://img.shields.io/github/downloads/release-argus/argus/total)](https://github.com/release-argus/Argus/releases) -[![GitHub release (latest by SemVer)](https://img.shields.io/github/downloads/release-argus/argus/latest/total)](https://github.com/release-argus/Argus/releases/latest) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/release-argus/Argus)](https://github.com/release-argus/Argus/releases) +[![GitHub all releases](https://img.shields.io/github/downloads/release-argus/Argus/total)](https://github.com/release-argus/Argus/releases) +[![GitHub release (latest by SemVer)](https://img.shields.io/github/downloads/release-argus/Argus/latest/total)](https://github.com/release-argus/Argus/releases/latest)
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/release-argus/Argus/build-docker.yml)](https://github.com/release-argus/Argus/actions/workflows/build-docker.yml) [![Docker Image Version (latest semver)](https://img.shields.io/docker/v/releaseargus/argus?sort=semver)](https://hub.docker.com/r/releaseargus/argus/tags) @@ -25,7 +25,7 @@ Keeping an eye on releases. Argus will query websites at a user defined interval for new software releases and then trigger Gotify/Slack/Other notification(s) and/or WebHook(s) when one has been found. -For example, you could set it to monitor the Argus repo ([release-argus/argus](https://github.com/release-argus/Argus)). This will query the [GitHub API](https://api.github.com/repos/release-argus/argus/releases) and track the "tag_name" variable. When this variable changes from what it was on a previous query, a GitHub-style WebHook could be sent that triggers something (like AWX) to update Argus on your server. +For example, you could set it to monitor the Argus repo ([release-argus/Argus](https://github.com/release-argus/Argus)). This will query the [GitHub API](https://api.github.com/repos/release-argus/Argus/releases) and track the "tag_name" variable. When this variable changes from what it was on a previous query, a GitHub-style WebHook could be sent that triggers something (like AWX) to update Argus on your server. ##### Table of Contents diff --git a/config.yml.example b/config.yml.example index 96b883c4..3daf5780 100644 --- a/config.yml.example +++ b/config.yml.example @@ -1,8 +1,8 @@ service: - release-argus/argus: + release-argus/Argus: latest_version: type: github - url: release-argus/argus + url: release-argus/Argus dashboard: icon: https://raw.githubusercontent.com/release-argus/Argus/master/web/ui/react-app/public/favicon.svg icon_link-to: https://release-argus.io diff --git a/config/decode/json_test.go b/config/decode/json_test.go index f5f0d5a2..e661c925 100644 --- a/config/decode/json_test.go +++ b/config/decode/json_test.go @@ -18,11 +18,11 @@ package decode import ( "fmt" - "regexp" "strings" "testing" "github.com/release-argus/Argus/internal/test" + "github.com/release-argus/Argus/util" "github.com/release-argus/Argus/util/errfmt" ) @@ -121,7 +121,7 @@ func TestParseKeys(t *testing.T) { // AND: the error is returned correctly. e := errfmt.FormatError(err) - if !regexp.MustCompile(tc.errRegex).MatchString(e) { + if !util.RegexCheck(tc.errRegex, e) { t.Errorf( "%s error mismatch\ngot: %q\nwant: %q", prefix, e, tc.errRegex, @@ -416,7 +416,7 @@ func TestNavigateJSON(t *testing.T) { // AND: the error is returned correctly. e := errfmt.FormatError(err) - if !regexp.MustCompile(tc.errRegex).MatchString(e) { + if !util.RegexCheck(tc.errRegex, e) { t.Errorf( "%s error mismatch\ngot: %q\nwant: %q", prefix, e, tc.errRegex, @@ -550,7 +550,7 @@ func TestGetValueByKey(t *testing.T) { // AND: the error is returned correctly. e := errfmt.FormatError(err) - if !regexp.MustCompile(tc.errRegex).MatchString(e) { + if !util.RegexCheck(tc.errRegex, e) { t.Errorf( "%s error mismatch\ngot: %q\nwant: %q", prefix, e, tc.errRegex, diff --git a/config/save.go b/config/save.go index 1e567fba..8bda67ac 100644 --- a/config/save.go +++ b/config/save.go @@ -95,13 +95,12 @@ func drainAndDebounce[T any](ctx context.Context, channel chan T, duration time. // Save writes the configuration to c.File. func (c *Config) Save() (ok bool) { - // Lock the config. - c.OrderMu.Lock() - defer c.OrderMu.Unlock() + c.OrderMu.RLock() // Encode to memory (Go-ordered slices, but with an order list for Services). var buf bytes.Buffer if err := encodeConfigYAML(&buf, int(c.Settings.Indentation), c); err != nil { + c.OrderMu.RUnlock() logx.Fatal( fmt.Sprintf("error encoding config: %v", err), logx.LogFrom{}, @@ -112,6 +111,7 @@ func (c *Config) Save() (ok bool) { // Reorder and clean the YAML in memory. lines := strings.Split(string(util.NormaliseNewlines(buf.Bytes())), "\n") lines = c.reorderYAML(lines) + c.OrderMu.RUnlock() // Open the file. file, err := openSaveFile(c.File) diff --git a/internal/test/marshal_test.go b/internal/test/marshal_test.go index acab4a8e..6c99b880 100644 --- a/internal/test/marshal_test.go +++ b/internal/test/marshal_test.go @@ -18,10 +18,10 @@ package test import ( "fmt" + "regexp" "testing" "github.com/goccy/go-yaml" - "github.com/release-argus/Argus/util" "github.com/release-argus/Argus/util/errfmt" ) @@ -111,7 +111,7 @@ func TestUnmarshal(t *testing.T) { // THEN: the error is as expected. e := errfmt.FormatError(err) - if !util.RegexCheck(tc.errRegex, e) { + if !regexp.MustCompile(tc.errRegex).MatchString(e) { t.Errorf( "%s error mismatch\ngot: %q\nwant: %q", prefix, e, tc.errRegex, diff --git a/package-lock.json b/package-lock.json index 8a8e6664..bebc69da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "0.29.4", "license": "ISC", "dependencies": { - "@commitlint/cli": "^21.0.2", - "@commitlint/config-conventional": "^21.0.2" + "@commitlint/cli": "^21.1.0", + "@commitlint/config-conventional": "^21.1.0" }, "devDependencies": { "husky": "9.1.7" @@ -40,16 +40,17 @@ } }, "node_modules/@commitlint/cli": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.0.2.tgz", - "integrity": "sha512-YMmfLbqBg+ZRvvmPhc+cilSQFrh/AgzVgCT1U/OifmUZEwPbvCtA8rN//YNaF9d5eoZphxVMGYtmwA2QgQORgg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.1.0.tgz", + "integrity": "sha512-CVwY6TxGv5naEaWxBdgNHko1xgL95Mb4WcIqp9iik33H0ctVqRv6YtekCntayhEP0T/apuiGvHu5HcCwFuVxEA==", "license": "MIT", "dependencies": { - "@commitlint/format": "^21.0.1", - "@commitlint/lint": "^21.0.2", - "@commitlint/load": "^21.0.2", - "@commitlint/read": "^21.0.2", - "@commitlint/types": "^21.0.1", + "@commitlint/config-conventional": "^21.1.0", + "@commitlint/format": "^21.1.0", + "@commitlint/lint": "^21.1.0", + "@commitlint/load": "^21.1.0", + "@commitlint/read": "^21.1.0", + "@commitlint/types": "^21.1.0", "tinyexec": "^1.0.0", "yargs": "^18.0.0" }, @@ -61,12 +62,12 @@ } }, "node_modules/@commitlint/config-conventional": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.0.2.tgz", - "integrity": "sha512-P/ZRhryQmkj0Z0dY9FOoRwe3xkwJyyAdtXwt01NT2kuZttcG2CNYp1q5Ci3u+nDT2jcbJRw2kt13Czl1qKNPfg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.1.0.tgz", + "integrity": "sha512-BIFl8xM+3SLy3jrblUC3wmQLCVbLty+++6o859BDCmybVrQdXmIWO+dlkGIbv/M2bBoC55wGuh0zGiw3TPjL1g==", "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "conventional-changelog-conventionalcommits": "^9.2.0" }, "engines": { @@ -74,12 +75,12 @@ } }, "node_modules/@commitlint/config-validator": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.0.1.tgz", - "integrity": "sha512-Zd2UFdndeMMaW2O96HK0tdfT4gOImUvidMpAd/pws2zZ4m1nrAZ/9b/v2JYuE8fs86GpXv9F7LNaIuCIWhY+pA==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.1.0.tgz", + "integrity": "sha512-gHczt1xqQSwfNqBmOI3HjejtTljkiBEUneExMmTBLD0WwTC78lAqDvNMyydbySt3DhpH0F9oX7Vvuks6s5XPFw==", "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "ajv": "^8.11.0" }, "engines": { @@ -87,12 +88,12 @@ } }, "node_modules/@commitlint/ensure": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.0.1.tgz", - "integrity": "sha512-jJ1037967wU7YN/xkv+iRlOBlmaOXPhPO5KQSqya6GyXzBlwuLzELBFao16DVg9dZyqmNrhewzwZ3SAibetHBQ==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.1.0.tgz", + "integrity": "sha512-/S8Mo3Q1NtQUYDQjDmyQVPxfIwtnxq+guzMOkuGk8OSdwlzanm1WB9wDPIuuzlbMDDnBNbiAuBEUCcCNlfjrTQ==", "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "es-toolkit": "^1.46.0" }, "engines": { @@ -109,12 +110,12 @@ } }, "node_modules/@commitlint/format": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.0.1.tgz", - "integrity": "sha512-ksmG2+cHGtuDPQQbhBbC4unwm444+6TiPw0d1bKf67hntgZqZ8E0g1MuYKUuyT5IH4IMmXZhKq22/Z3jBvtQIw==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.1.0.tgz", + "integrity": "sha512-ySymqKYBfjNrQ5N4W/l1iF2ISW1W7Eu/Oi/wRxlri31N0yjNyzUyUzQwyuZLDzTXIlMs4IZ7hIOfAZx8lO18gA==", "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "picocolors": "^1.1.1" }, "engines": { @@ -122,12 +123,12 @@ } }, "node_modules/@commitlint/is-ignored": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.0.2.tgz", - "integrity": "sha512-H5z4t8PC9tUsmZ/o+EptM3Nq8sTFtskAShdcqxCoyzklW5eaVT5xbrDAET2uypzir9Vsj4ZZmBtyKjYe2XqgeQ==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.1.0.tgz", + "integrity": "sha512-RoRh1/YI+fYH+aid5lMQ2UD0vZ3p3Vf1KeUWT1ir3H/p/7T/6SFv1OiXLgLwUT8dP72EVWeEIyOfkiSWLZYVvw==", "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "semver": "^7.6.0" }, "engines": { @@ -135,30 +136,30 @@ } }, "node_modules/@commitlint/lint": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.0.2.tgz", - "integrity": "sha512-PnUmLYGeGLfW8oVatR9KpNxSHYAnJOEWlMZzfdeFOUq6WUrFx1fGQaWCWJqMoIll/xPM+GdfJV+tKHZVHhl0Fg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.1.0.tgz", + "integrity": "sha512-0DbfVVUjAWBfixW6v7CXXWVxMcj6Ukf/oB7O8NAbouP3jxmqUaC4eVQphxl3B3M0ii3cCQiR3sRAYxICwU2gAA==", "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^21.0.2", - "@commitlint/parse": "^21.0.2", - "@commitlint/rules": "^21.0.2", - "@commitlint/types": "^21.0.1" + "@commitlint/is-ignored": "^21.1.0", + "@commitlint/parse": "^21.1.0", + "@commitlint/rules": "^21.1.0", + "@commitlint/types": "^21.1.0" }, "engines": { "node": ">=22.12.0" } }, "node_modules/@commitlint/load": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.0.2.tgz", - "integrity": "sha512-lwUE70hN0/qE/ZRROhbaX65ly/FF12DrqfReLCESo37M0OQCFAf2jRS+2tSCSORq+bm4Kdju7qNDj46uc1QzTA==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.1.0.tgz", + "integrity": "sha512-juiClVEcoreNB0TNVkseO2EmNcpEs/Yhnmgbnm/hQAKBFRynKwIaoNIljXkx/3yvZcMO0EE8I2XOEI7d5KZG8Q==", "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^21.0.1", + "@commitlint/config-validator": "^21.1.0", "@commitlint/execute-rule": "^21.0.1", - "@commitlint/resolve-extends": "^21.0.1", - "@commitlint/types": "^21.0.1", + "@commitlint/resolve-extends": "^21.1.0", + "@commitlint/types": "^21.1.0", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", "es-toolkit": "^1.46.0", @@ -179,12 +180,12 @@ } }, "node_modules/@commitlint/parse": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.0.2.tgz", - "integrity": "sha512-QVZJhGHTm+oiuWyEKOCTQ0ZM3mfJ0eGWFeHuj7WzSKEth+UukcCHac9GD8pgdFlg/qGkFWOtyaNd1T8REgagaw==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.1.0.tgz", + "integrity": "sha512-HdAqbbjQS8eEtbR74Ysg2VNmbvAfeWLVYMkip/lHibNrtjRsC/97XAYN3/H5P0pEJtDfyTb3iLs8x6y0eu4OYA==", "license": "MIT", "dependencies": { - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "conventional-changelog-angular": "^8.2.0", "conventional-commits-parser": "^6.3.0" }, @@ -193,13 +194,13 @@ } }, "node_modules/@commitlint/read": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.0.2.tgz", - "integrity": "sha512-BtsrnLVycSSKf4Q0gMch4giCj5NNlmcbhc8ra5vONgGtP2IjRDo33bEFtr5Pm+2N+5fXGWb2MksWPrspPfdhdw==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.1.0.tgz", + "integrity": "sha512-ID7m79aw8d0dMlxuXHD2QGxEX3Fhl/mUPA80WwEW5VgeOpUHNahhwWJefDdoBDVZcDfbHuf429NrcK0gxQsQjA==", "license": "MIT", "dependencies": { "@commitlint/top-level": "^21.0.2", - "@commitlint/types": "^21.0.1", + "@commitlint/types": "^21.1.0", "git-raw-commits": "^5.0.0", "tinyexec": "^1.0.0" }, @@ -208,13 +209,13 @@ } }, "node_modules/@commitlint/resolve-extends": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.0.1.tgz", - "integrity": "sha512-0DhjYWL6uYrY16Efa032fYk3woGJDU4AGWiG1XXltT9AMUNYKyb5cIZU2ivbaMZ3+kKFqUjikD2cjh66Sbh/Sg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.1.0.tgz", + "integrity": "sha512-SANYkxJDfMl3TvnyALWHEaiF5nc6FFaOnh7VvfxjT4X2vD4i2gVHhmfMm1fsrBwDRX98/XyM1XDo5sAd/KXcyQ==", "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^21.0.1", - "@commitlint/types": "^21.0.1", + "@commitlint/config-validator": "^21.1.0", + "@commitlint/types": "^21.1.0", "es-toolkit": "^1.46.0", "global-directory": "^5.0.0", "resolve-from": "^5.0.0" @@ -224,15 +225,15 @@ } }, "node_modules/@commitlint/rules": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.0.2.tgz", - "integrity": "sha512-k6tQ69Td7t2qUSIbik8D3TL1q3ZJpkEbV+yLogDzCRAdOxJm4ndhtBNREsLA1/puRfWvzS9eioF2w43WT+hHgQ==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.1.0.tgz", + "integrity": "sha512-fOPEYSmKn1ZJptjLmCEjJfYqz0PUYr8ng6VY2ZW26sB7KtENR90CmAXHEmScBbOIZip+d/+OwqK12DFBuHTqsQ==", "license": "MIT", "dependencies": { - "@commitlint/ensure": "^21.0.1", + "@commitlint/ensure": "^21.1.0", "@commitlint/message": "^21.0.2", "@commitlint/to-lines": "^21.0.1", - "@commitlint/types": "^21.0.1" + "@commitlint/types": "^21.1.0" }, "engines": { "node": ">=22.12.0" @@ -260,9 +261,9 @@ } }, "node_modules/@commitlint/types": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.0.1.tgz", - "integrity": "sha512-4u7w8jcoCUFWhjWnASYzZHAP34OqOtuFBN87nQmFvqda03YU0T6z+yB4w0gSAMpekiRqqGk5rt+qSlW+a2vSEg==", + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.1.0.tgz", + "integrity": "sha512-YodnnnH1Cp+08nP8HGNJAIuB6L3/vdCTHVRTfF8Ik/wRCLOTsU9zwv3yO1cSPQRDa9CLYtE+UJ2K67r7CwMSFw==", "license": "MIT", "dependencies": { "conventional-commits-parser": "^6.3.0", @@ -326,9 +327,9 @@ } }, "node_modules/@types/node": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", - "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", + "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", "license": "MIT", "peer": true, "dependencies": { @@ -540,9 +541,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.48.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.48.1.tgz", - "integrity": "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==", + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", "license": "MIT", "workspaces": [ "docs", @@ -605,6 +606,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", "integrity": "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==", + "deprecated": "Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.", "license": "MIT", "dependencies": { "@conventional-changelog/git-client": "^2.6.0", diff --git a/package.json b/package.json index a5a3ce51..d81a4fb5 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "author": "", "dependencies": { - "@commitlint/cli": "^21.0.2", - "@commitlint/config-conventional": "^21.0.2" + "@commitlint/cli": "^21.1.0", + "@commitlint/config-conventional": "^21.1.0" }, "description": "", "devDependencies": { diff --git a/service/deployed_version/types/manual/verify.go b/service/deployed_version/types/manual/verify.go index 8c814a0d..5d97efa3 100644 --- a/service/deployed_version/types/manual/verify.go +++ b/service/deployed_version/types/manual/verify.go @@ -22,5 +22,11 @@ import ( // CheckValues validates the fields of the receiver. func (l *Lookup) CheckValues() error { logFrom := logx.LogFrom{Primary: l.GetServiceID()} + + // Apply/validate the version without broadcasting it. + announceChannel := l.Status.AnnounceChannel + l.Status.AnnounceChannel = nil + defer func() { l.Status.AnnounceChannel = announceChannel }() + return l.Query(false, logFrom) } diff --git a/service/deployed_version/types/manual/verify_test.go b/service/deployed_version/types/manual/verify_test.go index 3d44a861..99a54217 100644 --- a/service/deployed_version/types/manual/verify_test.go +++ b/service/deployed_version/types/manual/verify_test.go @@ -17,6 +17,7 @@ package manual import ( + "fmt" "testing" "github.com/release-argus/Argus/internal/test" @@ -78,11 +79,20 @@ func TestLookup_CheckValues(t *testing.T) { input.CheckValues, ) + prefix := fmt.Sprintf("%s\nLookup.CheckValues()", packageName) + // AND: the version is set as expected. if got := input.Status.DeployedVersion(); got != tc.wantVersion { t.Errorf( - "%s\nLookup.CheckValues() .DeployedVersion() mismatch\ngot: %q\nwant: %q", - packageName, got, tc.wantVersion, + "%s .DeployedVersion() mismatch\ngot: %q\nwant: %q", + prefix, got, tc.wantVersion, + ) + } + // AND: nothing was broadcast to the Announce channel. + if got, want := len(input.Status.AnnounceChannel), 0; got != want { + t.Errorf( + "%s Announce channel length mismatch\ngot: %d\nwant: %d", + prefix, got, want, ) } }) diff --git a/web/api/types/argus.go b/web/api/types/argus.go index 6fae7eac..ab53209f 100644 --- a/web/api/types/argus.go +++ b/web/api/types/argus.go @@ -450,6 +450,7 @@ func (r *LatestVersion) String() string { // LatestVersionDefaults are default values for a LatestVersion. type LatestVersionDefaults struct { + Type string `json:"type,omitempty" yaml:"type,omitempty"` // "github" | "url". URL string `json:"url,omitempty" yaml:"url,omitempty"` // URL to query. AccessToken string `json:"access_token,omitempty" yaml:"access_token,omitempty"` // GitHub access token to use. AllowInvalidCerts *bool `json:"allow_invalid_certs,omitempty" yaml:"allow_invalid_certs,omitempty"` // Default - false = Disallows invalid HTTPS certificates. @@ -607,6 +608,7 @@ type RequireDocker struct { } type DeployedVersionLookupDefaults struct { + Type string `json:"type,omitempty" yaml:"type,omitempty"` // "manual" | "url". AllowInvalidCerts *bool `json:"allow_invalid_certs,omitempty" yaml:"allow_invalid_certs,omitempty"` // Disallows invalid HTTPS certificates. Method string `json:"method,omitempty" yaml:"method,omitempty"` // HTTP method. } diff --git a/web/api/v1/api.go b/web/api/v1/api.go index 40df8da0..1c80e4a9 100644 --- a/web/api/v1/api.go +++ b/web/api/v1/api.go @@ -19,6 +19,7 @@ import ( "fmt" "net/http" "strings" + "sync" "github.com/gorilla/mux" @@ -34,6 +35,19 @@ type API struct { BaseRouter *mux.Router Router *mux.Router RoutePrefix string + + serviceOpMu sync.Mutex // Guards [API.serviceOps]. + // serviceOps is a per-service-ID operation lock. Create/Edit/Delete take it + // exclusively (write), refreshes take it shared (read); a request that cannot + // take it is rejected with 409. Entries are reference-counted and removed once + // no request holds or is waiting on them. + serviceOps map[string]*serviceOp +} + +// serviceOp is a reference-counted per-service operation lock. +type serviceOp struct { + mu sync.RWMutex + refs int // Live acquireServiceOp callers; guarded by [API.serviceOpMu]. } // NewAPI creates a new API with the provided config. @@ -74,6 +88,35 @@ func NewAPI(cfg *config.Config) *API { return api } +// acquireServiceOp returns serviceID's operation lock, creating it on first use, +// and takes a reference so the entry survives until released. Pair every call +// with a [API.releaseServiceOp]. +func (api *API) acquireServiceOp(serviceID string) *serviceOp { + api.serviceOpMu.Lock() + defer api.serviceOpMu.Unlock() + if api.serviceOps == nil { + api.serviceOps = make(map[string]*serviceOp) + } + op := api.serviceOps[serviceID] + if op == nil { + op = &serviceOp{} + api.serviceOps[serviceID] = op + } + op.refs++ + return op +} + +// releaseServiceOp drops a reference taken by acquireServiceOp, removing the entry +// once the last reference is gone. +func (api *API) releaseServiceOp(serviceID string, op *serviceOp) { + api.serviceOpMu.Lock() + defer api.serviceOpMu.Unlock() + op.refs-- + if op.refs == 0 { + delete(api.serviceOps, serviceID) + } +} + // writeJSON marshals v as JSON and writes it to w with standard API response headers. func (api *API) writeJSON(w http.ResponseWriter, v any, logFrom logx.LogFrom) { w.Header().Set("Content-Type", "application/json; charset=utf-8") diff --git a/web/api/v1/api_test.go b/web/api/v1/api_test.go index 70c28e69..13c8d5c3 100644 --- a/web/api/v1/api_test.go +++ b/web/api/v1/api_test.go @@ -192,6 +192,87 @@ func TestNewAPI(t *testing.T) { } } +func TestAPI__ServiceOp(t *testing.T) { + // GIVEN: an API with no in-flight service operations. + api := &API{} + + // WHEN: the op lock is acquired twice for one id and once for another. + opA1 := api.acquireServiceOp("A") + opA2 := api.acquireServiceOp("A") + opB := api.acquireServiceOp("B") + + // THEN: the same lock is returned for the same id, a distinct one otherwise. + if opA1 != opA2 { + t.Errorf( + "%s\nacquireServiceOp(%q) returned different locks for the same id", + packageName, "A", + ) + } + if opA1 == opB { + t.Errorf("%s\nacquireServiceOp returned the same lock for different ids", packageName) + } + + // AND: the map and reference counts reflect the acquisitions. + if got, want := len(api.serviceOps), 2; got != want { + t.Errorf( + "%s\nserviceOps length mismatch\ngot: %d\nwant: %d", + packageName, got, want, + ) + } + if got, want := opA1.refs, 2; got != want { + t.Errorf( + "%s\nrefs for %q mismatch\ngot: %d\nwant: %d", + packageName, "A", got, want, + ) + } + + // WHEN: one of "A"'s two references is released. + api.releaseServiceOp("A", opA1) + + // THEN: the entry survives (still referenced). + if _, ok := api.serviceOps["A"]; !ok { + t.Errorf( + "%s\nserviceOps[%q] removed while still referenced", + packageName, "A", + ) + } + if got, want := opA2.refs, 1; got != want { + t.Errorf( + "%s\nrefs for %q mismatch\ngot: %d\nwant: %d", + packageName, "A", got, want, + ) + } + + // WHEN: the last reference to "A" is released. + api.releaseServiceOp("A", opA2) + // THEN: the entry is removed. + if _, ok := api.serviceOps["A"]; ok { + t.Errorf( + "%s\nserviceOps[%q] not removed after last reference released", + packageName, "A", + ) + } + // AND: a fresh acquire creates a new lock rather than reusing the removed one. + opA3 := api.acquireServiceOp("A") + if opA3 == opA1 { + t.Errorf( + "%s\nacquireServiceOp(%q) reused a removed lock", + packageName, "A", + ) + } + + // WHEN: every reference is released. + api.releaseServiceOp("A", opA3) + api.releaseServiceOp("B", opB) + // THEN: the map is empty. + if got := len(api.serviceOps); got != 0 { + t.Errorf( + "%s\nserviceOps not empty after all releases\ngot: %d", + packageName, got, + ) + } +} + func TestWriteJSON(t *testing.T) { // GIVEN: different input strings to write. tests := []struct { diff --git a/web/api/v1/convert.go b/web/api/v1/convert.go index 9a51bbfd..f64cb4c0 100644 --- a/web/api/v1/convert.go +++ b/web/api/v1/convert.go @@ -51,12 +51,14 @@ func convertAndCensorDefaults(input *config.Defaults) apitype.Defaults { SemanticVersioning: input.Service.Options.SemanticVersioning, }, LatestVersion: apitype.LatestVersionDefaults{ + Type: input.Service.LatestVersion.Type, AccessToken: util.ValueUnlessZero(input.Service.LatestVersion.AccessToken, util.SecretValue), AllowInvalidCerts: input.Service.LatestVersion.AllowInvalidCerts, UsePreRelease: input.Service.LatestVersion.UsePreRelease, Require: convertAndCensorLatestVersionRequireDefaults(&input.Service.LatestVersion.Require), }, DeployedVersionLookup: apitype.DeployedVersionLookupDefaults{ + Type: input.Service.DeployedVersionLookup.Type, AllowInvalidCerts: input.Service.DeployedVersionLookup.AllowInvalidCerts, Method: input.Service.DeployedVersionLookup.Method, }, @@ -139,13 +141,26 @@ func convertAndCensorLatestVersion(input latestver.Lookup) *apitype.LatestVersio Require: convertAndCensorLatestVersionRequire(lv.Require), } case *lvweb.Lookup: - return &apitype.LatestVersion{ + apiLV := &apitype.LatestVersion{ Type: lv.Type, URL: lv.URL, AllowInvalidCerts: lv.AllowInvalidCerts, URLCommands: convertURLCommands(lv.URLCommands), Require: convertAndCensorLatestVersionRequire(lv.Require), } + + // Headers (censoring each value). + if len(lv.Headers) > 0 { + apiLV.Headers = make([]apitype.Header, len(lv.Headers)) + for i := range lv.Headers { + apiLV.Headers[i] = apitype.Header{ + Key: lv.Headers[i].Key, + Value: util.SecretValue, + } + } + } + + return apiLV } return nil diff --git a/web/api/v1/convert_test.go b/web/api/v1/convert_test.go index a717265a..0c7eaac6 100644 --- a/web/api/v1/convert_test.go +++ b/web/api/v1/convert_test.go @@ -92,12 +92,146 @@ func TestConvertAndCensorDefaults(t *testing.T) { }, }, }, + { + name: "censor service.deployed_version", + input: &config.Defaults{ + Service: service.Defaults{ + DeployedVersionLookup: dvbase.Defaults{ + Type: "url", + AllowInvalidCerts: test.Ptr(true), + Method: "GET", + }, + }, + }, + want: apitype.Defaults{ + Service: apitype.ServiceDefaults{ + DeployedVersionLookup: apitype.DeployedVersionLookupDefaults{ + Type: "url", + AllowInvalidCerts: test.Ptr(true), + Method: "GET", + }, + }, + }, + }, + { + name: "service.dashboard", + input: &config.Defaults{ + Service: service.Defaults{ + Dashboard: dashboard.Defaults{ + OptionsBase: dashboard.OptionsBase{ + AutoApprove: test.Ptr(true), + Icon: "https://example.com/icon.png", + IconLinkTo: "https://example.com", + WebURL: "https://example.com/other", + }, + }, + }, + }, + want: apitype.Defaults{ + Service: apitype.ServiceDefaults{ + Dashboard: apitype.DashboardOptions{ + AutoApprove: test.Ptr(true), + }, + }, + }, + }, + { + name: "service.command", + input: &config.Defaults{ + Service: service.Defaults{ + Command: command.Commands{ + {"echo", "hello"}, + }, + }, + }, + want: apitype.Defaults{ + Service: apitype.ServiceDefaults{ + Command: apitype.Commands{ + {"echo", "hello"}, + }, + }, + }, + }, + { + name: "service.notify", + input: &config.Defaults{ + Service: service.Defaults{ + Notify: map[string]struct{}{ + "foo": {}, + "bar": {}, + }, + }, + }, + want: apitype.Defaults{ + Service: apitype.ServiceDefaults{ + Notify: []string{"bar", "foo"}, + }, + }, + }, + { + name: "censor notify", + input: &config.Defaults{ + Notify: shoutrrr.ShoutrrrsDefaults{ + "other": shoutrrr.NewDefaults( + "discord", + map[string]string{ + "message": "release {{ version }} is available", + }, + map[string]string{ + "apikey": "censor?", + }, + map[string]string{ + "devices": "censor this too", + "avatar": "https://example.com", + }, + ), + }, + }, + want: apitype.Defaults{ + Notify: apitype.Notifiers{ + "other": { + Type: "discord", + Options: map[string]string{ + "message": "release {{ version }} is available", + }, + URLFields: map[string]string{ + "apikey": util.SecretValue, + }, + Params: map[string]string{ + "devices": util.SecretValue, + "avatar": "https://example.com", + }, + }, + }, + }, + }, + { + name: "censor webhook", + input: &config.Defaults{ + WebHook: *test.Must(t, func() (*webhook.Defaults, error) { + return webhook.DecodeDefaults( + "yaml", []byte(test.TrimYAML(` + type: github + url: https://example.com + secret: censor + `)), + ) + }), + }, + want: apitype.Defaults{ + WebHook: apitype.WebHook{ + Type: "github", + URL: "https://example.com", + Secret: util.SecretValue, + }, + }, + }, { name: "censor service.latest_version", input: &config.Defaults{ Service: service.Defaults{ - Options: opt.Defaults{}, LatestVersion: lvbase.Defaults{ + Type: "github", AccessToken: "censor", Require: *test.Must(t, func() (*filter.RequireDefaults, error) { return filter.DecodeDefaults( @@ -129,14 +263,12 @@ func TestConvertAndCensorDefaults(t *testing.T) { ) }), }, - DeployedVersionLookup: dvbase.Defaults{}, - Dashboard: dashboard.Defaults{}, }, }, want: apitype.Defaults{ Service: apitype.ServiceDefaults{ - Options: apitype.ServiceOptions{}, LatestVersion: apitype.LatestVersionDefaults{ + Type: "github", AccessToken: util.SecretValue, Require: &apitype.LatestVersionRequireDefaults{ Docker: apitype.RequireDockerDefaults{ @@ -178,9 +310,6 @@ func TestConvertAndCensorDefaults(t *testing.T) { }, }, }, - Command: apitype.Commands{}, - DeployedVersionLookup: apitype.DeployedVersionLookupDefaults{}, - Dashboard: apitype.DashboardOptions{}, }, }, }, @@ -218,6 +347,35 @@ func TestConvertAndCensorDefaults(t *testing.T) { } } +func TestConvertAndCensorDefaults__Default(t *testing.T) { + // GIVEN: a bare config.Defaults populated with its hard-coded defaults. + defaults := &config.Defaults{} + defaults.Default() + had := defaults.String("") + + // WHEN: convertAndCensorDefaults is called. + result := convertAndCensorDefaults(defaults) + + prefix := fmt.Sprintf("%s\nconvertAndCensorDefaults()", packageName) + + // THEN: the Defaults are converted as expected. + got := result.String() + if got != stringifiedConvertedDefaults { + t.Fatalf( + "%s .Defaults() stringified mismatch\ngot: %q\nwant: %q", + prefix, got, stringifiedConvertedDefaults, + ) + } + + // AND: the Defaults are unchanged. + if got := defaults.String(""); got != had { + t.Fatalf( + "%s input changed\ngot: %q\nhad: %q", + prefix, got, had, + ) + } +} + // // Service. // @@ -453,6 +611,9 @@ func TestConvertAndCensorLatestVersion(t *testing.T) { type: url allow_invalid_certs: true url: https://example.com + headers: + - key: X-Foo + value: not_telling_you url_commands: - type: replace old: this @@ -479,6 +640,9 @@ func TestConvertAndCensorLatestVersion(t *testing.T) { Type: "url", URL: "https://example.com", AllowInvalidCerts: test.Ptr(true), + Headers: []apitype.Header{ + {Key: "X-Foo", Value: util.SecretValue}, + }, URLCommands: apitype.URLCommands{ {Type: "replace", Old: "this", New: "withThis"}, {Type: "split", Text: "splitThis", Index: test.Ptr(8)}, @@ -2374,3 +2538,279 @@ func TestConvertWebHookHeaders(t *testing.T) { }) } } + +var stringifiedConvertedDefaults = test.TrimJSON(`{ + "service": { + "options": { + "interval": "10m", + "semantic_versioning": true + }, + "latest_version": { + "type": "github", + "allow_invalid_certs": false, + "use_prerelease": false, + "require": { + "docker": { + "type": "hub", + "tag": "{{ version }}" + } + } + }, + "deployed_version": { + "type": "url", + "allow_invalid_certs": false, + "method": "GET" + }, + "dashboard": { + "auto_approve": false + } + }, + "notify": { + "bark": { + "type": "bark", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + }, + "url_fields": { + "port": "443" + }, + "params": { + "title": "Argus" + } + }, + "discord": { + "type": "discord", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + }, + "params": { + "splitlines": "yes", + "username": "Argus" + } + }, + "generic": { + "type": "generic", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + }, + "params": { + "contenttype": "application/json", + "disabletls": "no", + "messagekey": "message", + "requestmethod": "POST", + "titlekey": "title" + } + }, + "googlechat": { + "type": "googlechat", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + } + }, + "gotify": { + "type": "gotify", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + }, + "url_fields": { + "port": "443" + }, + "params": { + "disabletls": "no", + "insecureskipverify": "no", + "priority": "0", + "title": "Argus", + "useheader": "no" + } + }, + "ifttt": { + "type": "ifttt", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + }, + "params": { + "messagevalue": "2", + "titlevalue": "0" + } + }, + "join": { + "type": "join", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + } + }, + "matrix": { + "type": "matrix", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + }, + "url_fields": { + "port": "443" + }, + "params": { + "disabletls": "no" + } + }, + "mattermost": { + "type": "mattermost", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "<{{ service_url }}|{{ service_name | default:service_id }}> - {{ version }} released{% if web_url %} (<{{ web_url }}|changelog>){% endif %}" + }, + "url_fields": { + "port": "443", + "username": "Argus" + }, + "params": { + "disabletls": "no" + } + }, + "notifiarr": { + "type": "notifiarr", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + } + }, + "ntfy": { + "type": "ntfy", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + }, + "url_fields": { + "host": "ntfy.sh" + }, + "params": { + "disabletlsverification": "no", + "title": "Argus" + } + }, + "opsgenie": { + "type": "opsgenie", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + } + }, + "pushbullet": { + "type": "pushbullet", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + }, + "params": { + "title": "Argus" + } + }, + "pushover": { + "type": "pushover", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + } + }, + "rocketchat": { + "type": "rocketchat", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + }, + "url_fields": { + "port": "443" + } + }, + "shoutrrr": { + "type": "shoutrrr", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + } + }, + "slack": { + "type": "slack", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + }, + "params": { + "botname": "Argus" + } + }, + "smtp": { + "type": "smtp", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + }, + "params": { + "requirestarttls": "no", + "skiptlsverify": "no", + "usehtml": "no", + "usestarttls": "yes" + } + }, + "teams": { + "type": "teams", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + } + }, + "telegram": { + "type": "telegram", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + }, + "params": { + "notification": "yes", + "preview": "yes" + } + }, + "zulip": { + "type": "zulip", + "options": { + "delay": "0s", + "max_tries": "3", + "message": "{{ service_name | default:service_id }} - {{ version }} released" + } + } + }, + "webhook": { + "type": "github", + "allow_invalid_certs": false, + "desired_status_code": 0, + "delay": "0s", + "max_tries": 3, + "silent_fails": false + } +}`) diff --git a/web/api/v1/help_test.go b/web/api/v1/help_test.go index 5c1c6ea2..63252e7b 100644 --- a/web/api/v1/help_test.go +++ b/web/api/v1/help_test.go @@ -59,6 +59,9 @@ func TestMain(m *testing.M) { ctx, cancel := context.WithCancel(context.Background()) g, _ := errgroup.WithContext(ctx) + // Shorten the WebSocket ping interval so writePump tests don't wait out the production default. + pingPeriod = 10 * time.Millisecond + config.DebounceDuration = 500 * time.Millisecond flags := make(map[string]bool) path := "TestWebAPIv1Main.yml" @@ -137,9 +140,9 @@ func plainDefaults(t *testing.T) (*config.Defaults, *config.Defaults) { func testClient() Client { hub := NewHub() return Client{ - hub: hub, - ip: "1.1.1.1", - conn: &websocket.Conn{}, + hub: hub, + ip: "1.1.1.1", + conn: &websocket.Conn{}, send: make(chan []byte, 5), } } diff --git a/web/api/v1/http-api-edit.go b/web/api/v1/http-api-edit.go index a253ae43..5f1d75db 100644 --- a/web/api/v1/http-api-edit.go +++ b/web/api/v1/http-api-edit.go @@ -237,12 +237,26 @@ func (api *API) httpLatestVersionRefresh(w http.ResponseWriter, r *http.Request) return } + // Refreshes take the per-service lock shared; reject if an edit/delete holds it. + op := api.acquireServiceOp(serviceID) + defer api.releaseServiceOp(serviceID, op) + if !op.mu.TryRLock() { + failRequest( + &w, + fmt.Errorf("refresh %q failed, another operation is in progress for this service", serviceID), + http.StatusConflict, + ) + return + } + defer op.mu.RUnlock() + queryParams := r.URL.Query() // Check whether service exists. api.Config.OrderMu.RLock() - defer api.Config.OrderMu.RUnlock() - if api.Config.Service[serviceID] == nil { + svc := api.Config.Service[serviceID] + api.Config.OrderMu.RUnlock() + if svc == nil { err := fmt.Errorf("service %q not found", serviceID) logx.Error(err, logFrom, true) failRequest(&w, err, http.StatusNotFound) @@ -269,13 +283,13 @@ func (api *API) httpLatestVersionRefresh(w http.ResponseWriter, r *http.Request) // Query the latest version lookup. version, announce, err := latestver.Refresh( - api.Config.Service[serviceID].LatestVersion, + svc.LatestVersion, overrideBytes, semanticVersioning, secretRefs, ) if announce { - api.Config.Service[serviceID].HandleUpdateActions(true) + svc.HandleUpdateActions(true) } if err != nil { failRequest(&w, err, http.StatusBadRequest) @@ -315,12 +329,25 @@ func (api *API) httpDeployedVersionRefresh(w http.ResponseWriter, r *http.Reques return } + // Refreshes take the per-service lock shared; reject if an edit/delete holds it. + op := api.acquireServiceOp(serviceID) + defer api.releaseServiceOp(serviceID, op) + if !op.mu.TryRLock() { + failRequest( + &w, + fmt.Errorf("refresh %q failed, another operation is in progress for this service", serviceID), + http.StatusConflict, + ) + return + } + defer op.mu.RUnlock() + queryParams := r.URL.Query() // Check whether service exists. api.Config.OrderMu.RLock() - defer api.Config.OrderMu.RUnlock() svc := api.Config.Service[serviceID] + api.Config.OrderMu.RUnlock() if svc == nil { err := fmt.Errorf("service %q not found", serviceID) logx.Error(err, logFrom, true) @@ -369,7 +396,7 @@ func (api *API) httpDeployedVersionRefresh(w http.ResponseWriter, r *http.Reques dvl, _ = deployedver.Decode( "json", overrideBytes, - &api.Config.Service[serviceID].Options, + &svc.Options, &svcStatus, dvbase.DefaultsConfig{ Soft: &api.Config.Defaults.Service.DeployedVersionLookup, @@ -580,13 +607,24 @@ func (api *API) httpServiceEdit(w http.ResponseWriter, r *http.Request) { reqType = "edit" } + // EDIT: wait out any in-flight operations on this service (a refresh, another + // edit, or a delete) rather than failing fast, so a background refresh can't + // bounce a user's save. If the service was deleted or renamed while we waited, + // return 404. + if serviceID != "" { + op := api.acquireServiceOp(serviceID) + defer api.releaseServiceOp(serviceID, op) + op.mu.Lock() + defer op.mu.Unlock() + } + api.Config.OrderMu.RLock() - defer api.Config.OrderMu.RUnlock() var oldServiceSummary *apitype.ServiceSummary // EDIT the existing service. if serviceID != "" { if api.Config.Service[serviceID] == nil { + api.Config.OrderMu.RUnlock() failRequest( &w, fmt.Errorf("edit %q failed, service could not be found", serviceID), @@ -610,6 +648,7 @@ func (api *API) httpServiceEdit(w http.ResponseWriter, r *http.Request) { svcDefaults, notifyDefaults, webhookDefaults, logFrom, ) + api.Config.OrderMu.RUnlock() if err != nil { err = fmt.Errorf( `%s %q failed: %w`, @@ -620,9 +659,8 @@ func (api *API) httpServiceEdit(w http.ResponseWriter, r *http.Request) { return } - // CREATE a new service, but one with this ID already exists. + // CREATE/EDIT: service with this ID/Name already exists. if (serviceID == "" && api.Config.Service[newService.ID] != nil) || - // CREATE/EDIT, but a service with this name already exists. api.Config.ServiceWithNameExists(newService.Name, serviceID) { failRequest( &w, @@ -632,6 +670,23 @@ func (api *API) httpServiceEdit(w http.ResponseWriter, r *http.Request) { return } + // CREATE: new ID is known, reject a concurrent create of the same ID. + if serviceID == "" { + op := api.acquireServiceOp(newService.ID) + defer api.releaseServiceOp(newService.ID, op) + if !op.mu.TryLock() { + failRequest( + &w, + fmt.Errorf( + "create %q failed, another operation is in progress for this service", + newService.ID), + http.StatusConflict, + ) + return + } + defer op.mu.Unlock() + } + // Ensure LatestVersion and DeployedVersion (if set) can fetch. if err := newService.CheckFetches(); err != nil { err = fmt.Errorf( @@ -655,11 +710,16 @@ func (api *API) httpServiceEdit(w http.ResponseWriter, r *http.Request) { } // Add the new service to the config. - api.Config.OrderMu.RUnlock() // Locked above. - //#nosec G104 -- Fail for duplicate service name handled above. - //nolint:errcheck // ^ - _ = api.Config.AddService(serviceID, newService) - api.Config.OrderMu.RLock() // Lock again for the defer. + if err := api.Config.AddService(serviceID, newService); err != nil { + err = fmt.Errorf( + `%s %q failed: %w`, + reqType, util.FirstNonDefault(serviceID, newService.ID), + err, + ) + logx.Error(err, logFrom, true) + failRequest(&w, err, http.StatusBadRequest) + return + } newServiceSummary := newService.Summary() // Announce the edit. @@ -701,8 +761,17 @@ func (api *API) httpServiceDelete(w http.ResponseWriter, r *http.Request) { return } - // If service doesn't exist, return 404. - if api.Config.Service[serviceID] == nil { + // Delete waits out any in-flight operations on this service, then takes the lock exclusively. + op := api.acquireServiceOp(serviceID) + defer api.releaseServiceOp(serviceID, op) + op.mu.Lock() + defer op.mu.Unlock() + + // If the service no longer exists (e.g. an edit we waited out renamed it), then 404. + api.Config.OrderMu.RLock() + exists := api.Config.Service[serviceID] != nil + api.Config.OrderMu.RUnlock() + if !exists { failRequest( &w, fmt.Errorf("delete %q failed, service not found", serviceID), diff --git a/web/api/v1/http-api-edit_test.go b/web/api/v1/http-api-edit_test.go index 10b982d4..ad1bfd68 100644 --- a/web/api/v1/http-api-edit_test.go +++ b/web/api/v1/http-api-edit_test.go @@ -1777,6 +1777,56 @@ func TestHTTP_ServiceEdit__create(t *testing.T) { } } +func TestHTTP_ServiceEdit__create__concurrentConflict(t *testing.T) { + // GIVEN: an API where the op lock for a not-yet-created service ID is already + // held by an in-flight operation (e.g. a concurrent create of the same ID). + file := filepath.Join(t.TempDir(), "config.yml") + api := testAPI(t, file) + const serviceID = "TestHTTP_ServiceEdit_createConflict" + + held := api.acquireServiceOp(serviceID) + held.mu.Lock() + t.Cleanup(func() { + held.mu.Unlock() + api.releaseServiceOp(serviceID, held) + }) + + // WHEN: a create request for a service with that ID is sent. + payload := bytes.NewReader([]byte(test.TrimJSON(`{ + "id": "` + serviceID + `", + "options": { + "active": false + }, + "latest_version": { + "type": "github", + "url": "` + test.ArgusGitHubRepo + `" + } + }`))) + req := httptest.NewRequest(http.MethodPost, "/api/v1/service/new", payload) + w := httptest.NewRecorder() + api.httpServiceEdit(w, req) + res := w.Result() + t.Cleanup(func() { _ = res.Body.Close() }) + + prefix := fmt.Sprintf("%s\nAPI.httpServiceEdit() (create)", packageName) + + // THEN: the create is rejected with 409 rather than racing the in-flight op. + if got, want := res.StatusCode, http.StatusConflict; got != want { + t.Errorf( + "%s status code mismatch\ngot: %d\nwant: %d", + prefix, got, want, + ) + } + // AND: the body explains the conflict. + body, _ := io.ReadAll(res.Body) + if got, want := string(body), `another operation is in progress`; !util.RegexCheck(want, got) { + t.Errorf( + "%s body mismatch\ngot: %q\nwant: %q", + prefix, got, want, + ) + } +} + func TestHTTP_ServiceEdit__edit(t *testing.T) { svcCfg := svctest.PlainDefaultsConfig(t) notifyCfg := shoutrrrtest.PlainConfig(t) @@ -2139,6 +2189,64 @@ func TestHTTP_ServiceEdit__edit(t *testing.T) { } } +func TestHTTP_ServiceEdit__edit__renameToExistingID(t *testing.T) { + // GIVEN: an API with two services — one to edit, and one whose ID we rename onto. + file := filepath.Join(t.TempDir(), "config.yml") + api := testAPI(t, file) + + const ( + editID = "TestHTTP_ServiceEdit_rename-source" + targetID = "TestHTTP_ServiceEdit_rename-target" + ) + source := testService(t, editID, "url", "url", true) + source.Name = "rename-source-name" + target := testService(t, targetID, "url", "url", true) + target.Name = "rename-target-name" + api.Config.Service[source.ID] = source + api.Config.Service[target.ID] = target + api.Config.Order = append(api.Config.Order, source.ID, target.ID) + + // WHEN: an edit renames the source service onto the target's existing ID, + // using a unique name so the name pre-check does not catch it first. + payload := bytes.NewReader([]byte(test.TrimJSON(`{ + "id": "` + targetID + `", + "name": "rename-source-renamed", + "options": { + "active": false + }, + "latest_version": { + "type": "github", + "url": "` + test.ArgusGitHubRepo + `" + } + }`))) + params := url.Values{} + params.Set("service_id", editID) + req := httptest.NewRequest(http.MethodPut, "/api/v1/service/update", payload) + req.URL.RawQuery = params.Encode() + w := httptest.NewRecorder() + api.httpServiceEdit(w, req) + res := w.Result() + t.Cleanup(func() { _ = res.Body.Close() }) + + prefix := fmt.Sprintf("%s\nAPI.httpServiceEdit() (edit)", packageName) + + // THEN: AddService rejects the collision and the edit fails with 400. + if got, want := res.StatusCode, http.StatusBadRequest; got != want { + t.Errorf( + "%s status code mismatch\ngot: %d\nwant: %d", + prefix, got, want, + ) + } + // AND: the body reports the existing service. + body, _ := io.ReadAll(res.Body) + if got, want := string(body), `already exists`; !util.RegexCheck(want, got) { + t.Errorf( + "%s body mismatch\ngot: %q\nwant: %q", + prefix, got, want, + ) + } +} + func TestHTTP_ServiceEdit__edit__secrets(t *testing.T) { svcCfg := svctest.PlainDefaultsConfig(t) notifyCfg := shoutrrrtest.PlainConfig(t) @@ -2548,6 +2656,63 @@ func TestHTTP_ServiceEdit__edit__secrets(t *testing.T) { } } +func TestHTTP_ServiceEdit__edit__waitsForInFlightOp(t *testing.T) { + // GIVEN: an API and an absent service whose op lock is held by an in-flight + // operation (a refresh, or another edit/delete). + file := filepath.Join(t.TempDir(), "config.yml") + api := testAPI(t, file) + const serviceID = "TestHTTP_ServiceEdit_waits-absent" + + held := api.acquireServiceOp(serviceID) + held.mu.Lock() // In-flight operation holds the lock. + + // WHEN: an edit request for that service is sent while the lock is held. + params := url.Values{} + params.Set("service_id", serviceID) + req := httptest.NewRequest(http.MethodPut, "/api/v1/service/update", nil) + req.URL.RawQuery = params.Encode() + + done := make(chan int, 1) + go func() { + w := httptest.NewRecorder() + api.httpServiceEdit(w, req) + done <- w.Result().StatusCode + }() + + prefix := fmt.Sprintf( + "%s\n%s", + packageName, req.URL.RawPath, + ) + + // THEN: the edit blocks rather than failing fast with 409. + select { + case code := <-done: + t.Fatalf( + "%s completed (status %d) while the op lock was held; want it to block", + prefix, code, + ) + case <-time.After(1 * time.Second): + // Still blocked, as expected. + } + + // WHEN: the in-flight operation releases the lock. + held.mu.Unlock() + api.releaseServiceOp(serviceID, held) + + // THEN: the edit proceeds past the lock; the service is absent, so it 404s. + select { + case code := <-done: + if code != http.StatusNotFound { + t.Errorf( + "%s status mismatch\ngot: %d\nwant: %d", + prefix, code, http.StatusNotFound, + ) + } + case <-time.After(5 * time.Second): + t.Fatalf("%s did not complete after the op lock was released", prefix) + } +} + func TestHTTP_ServiceDelete(t *testing.T) { type wants struct { bodyRegex string @@ -3095,3 +3260,180 @@ func TestHTTP_NotifyTest(t *testing.T) { }) } } + +func TestHTTP_ServiceOpLock__conflict(t *testing.T) { + // hold describes how the test pre-holds a service's op lock before a request. + type hold int + const ( + holdNone hold = iota + holdWrite // Simulate an in-flight edit/delete (exclusive). + holdRead // Simulate an in-flight refresh (shared). + ) + + // GIVEN: an API. + file := filepath.Join(t.TempDir(), "config.yml") + api := testAPI(t, file) + + tests := map[string]struct { + handler func(http.ResponseWriter, *http.Request) + hold hold + statusCode int + bodyRegex string + }{ + "latest-version refresh rejected while an exclusive op is in flight": { + handler: api.httpLatestVersionRefresh, + hold: holdWrite, + statusCode: http.StatusConflict, + bodyRegex: `another operation is in progress`, + }, + "latest-version refresh allowed alongside another refresh (shared)": { + handler: api.httpLatestVersionRefresh, + hold: holdRead, + statusCode: http.StatusNotFound, // Past the lock, then the absent service. + bodyRegex: `not found`, + }, + "deployed-version refresh rejected while an exclusive op is in flight": { + handler: api.httpDeployedVersionRefresh, + hold: holdWrite, + statusCode: http.StatusConflict, + bodyRegex: `another operation is in progress`, + }, + "deployed-version refresh allowed alongside another refresh (shared)": { + handler: api.httpDeployedVersionRefresh, + hold: holdRead, + statusCode: http.StatusNotFound, + bodyRegex: `not found`, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + serviceID := name + + // GIVEN: the service's op lock is held as described. + held := api.acquireServiceOp(serviceID) + switch tc.hold { + case holdWrite: + held.mu.Lock() + case holdRead: + held.mu.RLock() + } + t.Cleanup(func() { + switch tc.hold { + case holdWrite: + held.mu.Unlock() + case holdRead: + held.mu.RUnlock() + } + api.releaseServiceOp(serviceID, held) + }) + + // WHEN: the handler is called for that service. + params := url.Values{} + params.Set("service_id", serviceID) + req := httptest.NewRequest(http.MethodGet, "/api/v1/service", nil) + req.URL.RawQuery = params.Encode() + w := httptest.NewRecorder() + tc.handler(w, req) + res := w.Result() + t.Cleanup(func() { _ = res.Body.Close() }) + + prefix := fmt.Sprintf( + "%s\n%s", + packageName, req.URL.RawPath, + ) + + // THEN: the expected status code is returned. + if got := res.StatusCode; got != tc.statusCode { + t.Errorf( + "%s status code mismatch\ngot: %d\nwant: %d", + prefix, got, tc.statusCode, + ) + } + // AND: the body matches. + body, _ := io.ReadAll(res.Body) + if got := string(body); !util.RegexCheck(tc.bodyRegex, got) { + t.Errorf( + "%s body mismatch\ngot: %q\nwant: %q", + prefix, got, tc.bodyRegex, + ) + } + }) + } +} + +func TestHTTP_ServiceDelete__waitsForInFlightOp(t *testing.T) { + // GIVEN: an API with a service whose op lock is held by an in-flight operation. + file := filepath.Join(t.TempDir(), "config.yml") + api := testAPI(t, file) + svc := testService(t, "TestHTTP_ServiceDelete_waits", "url", "url", true) + svc.HardDefaults.Status.DatabaseChannel = api.Config.DatabaseChannel + _ = api.Config.AddService("", svc) + <-api.Config.DatabaseChannel // Drain the addition. + t.Cleanup(func() { + // Give the post-delete config save time before TempDir cleanup. + time.Sleep(2 * config.DebounceDuration) + }) + + held := api.acquireServiceOp(svc.ID) + held.mu.Lock() // In-flight edit/refresh holds the lock. + + // WHEN: a delete request is sent while the lock is held. + params := url.Values{} + params.Set("service_id", svc.ID) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/service/delete", nil) + req.URL.RawQuery = params.Encode() + + done := make(chan int, 1) + go func() { + w := httptest.NewRecorder() + api.httpServiceDelete(w, req) + done <- w.Result().StatusCode + }() + + prefix := fmt.Sprintf( + "%s\n%s", + packageName, req.URL.RawPath, + ) + + // THEN: the delete blocks rather than failing fast. + select { + case code := <-done: + t.Fatalf( + "%s delete completed (status %d) while the op lock was held; want it to block", + prefix, code, + ) + case <-time.After(1 * time.Second): + // Still blocked, as expected. + } + + // WHEN: the in-flight operation releases the lock. + held.mu.Unlock() + api.releaseServiceOp(svc.ID, held) + + // THEN: the delete proceeds and succeeds. + select { + case code := <-done: + if code != http.StatusOK { + t.Errorf( + "%s delete status mismatch\ngot: %d\nwant: %d", + prefix, code, http.StatusOK, + ) + } + case <-time.After(5 * time.Second): + t.Fatalf("%s delete did not complete after the op lock was released", prefix) + } + + // AND: the op lock entry is cleaned up once idle. + api.serviceOpMu.Lock() + _, leaked := api.serviceOps[svc.ID] + api.serviceOpMu.Unlock() + if leaked { + t.Errorf( + "%s serviceOps[%q] leaked after the delete completed", + prefix, svc.ID, + ) + } +} diff --git a/web/api/v1/websocket-client_test.go b/web/api/v1/websocket-client_test.go index 0c7e9624..d3a67790 100644 --- a/web/api/v1/websocket-client_test.go +++ b/web/api/v1/websocket-client_test.go @@ -225,7 +225,7 @@ func TestClient_ReadPump__pongHandler(t *testing.T) { time.Sleep(100 * time.Millisecond) // THEN: the connection remains alive — pong handler ran without error. - if !wsTest.client.hub.clients[wsTest.client] { + if !wsTest.client.hub.hasClient(wsTest.client) { t.Errorf("%s client should still be registered to hub after pong", prefix) } } @@ -595,7 +595,6 @@ func TestClient_WritePump__connection(t *testing.T) { closeSend bool closeConnBeforePump bool closeConnAfterPump bool - shortenPing bool stdoutRegex string }{ { @@ -613,12 +612,10 @@ func TestClient_WritePump__connection(t *testing.T) { }, { name: "sends ping frames", - shortenPing: true, stdoutRegex: `^$`, }, { name: "ping write failure exits writePump", - shortenPing: true, closeConnAfterPump: true, stdoutRegex: `^$`, }, @@ -629,12 +626,6 @@ func TestClient_WritePump__connection(t *testing.T) { // t.Parallel() - Cannot run in parallel since we're using stdout. releaseStdout := test.CaptureLog(t, logx.Default()) - if tc.shortenPing { - oldPingPeriod := pingPeriod - pingPeriod = 10 * time.Millisecond - t.Cleanup(func() { pingPeriod = oldPingPeriod }) - } - // GIVEN: a Hub with a registered client. wsTest := setupWSHubClient(t) t.Cleanup(func() { wsTest.cleanup(t) }) @@ -758,7 +749,7 @@ func waitForClientUnregistered(t *testing.T, hub *Hub, client *Client) { deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - if !hub.clients[client] { + if !hub.hasClient(client) { return } time.Sleep(50 * time.Millisecond) @@ -893,19 +884,18 @@ func TestServeWs__plain_HTTP(t *testing.T) { } } +// hubClientForTest checks that hub has only 1 Client, and returns it if it does. func hubClientForTest(t *testing.T, hub *Hub) *Client { t.Helper() - if len(hub.clients) == 0 { + clients := hub.clientList() + if len(clients) == 0 { return nil } - if len(hub.clients) > 1 { - t.Fatalf("%s\nexpected one registered client, got %d", packageName, len(hub.clients)) + if len(clients) > 1 { + t.Fatalf("%s\nexpected one registered client, got %d", packageName, len(clients)) } - for client := range hub.clients { - return client - } - return nil + return clients[0] } diff --git a/web/api/v1/websocket-hub.go b/web/api/v1/websocket-hub.go index 79644dba..55fcc6a8 100644 --- a/web/api/v1/websocket-hub.go +++ b/web/api/v1/websocket-hub.go @@ -22,17 +22,18 @@ import ( // Hub maintains the set of active clients and broadcasts messages to those clients. type Hub struct { - // Registered clients. + // Registered clients (owned by the Run goroutine). clients map[*Client]bool - // Inbound messages from the clients. - Broadcast chan []byte - - // Register requests from the clients. - register chan *Client + Broadcast chan []byte // Inbound messages from the clients. + register chan *Client // Register requests from the clients. + unregister chan *Client // Unregister requests from clients. + query chan func(map[*Client]bool) // Observe clients on the Run goroutine. +} - // Unregister requests from clients. - unregister chan *Client +// clientCount returns the number of registered clients. +func (h *Hub) clientCount() int { + return len(h.clients) } // NewHub creates a new Hub. @@ -41,6 +42,7 @@ func NewHub() *Hub { Broadcast: make(chan []byte, 256), register: make(chan *Client), unregister: make(chan *Client), + query: make(chan func(map[*Client]bool)), clients: make(map[*Client]bool), } } @@ -51,49 +53,64 @@ type AnnounceMSG struct { ServiceID string `json:"service_id"` } -// Run starts the WebSocket Hub. +// addClient registers client. +func (h *Hub) addClient(client *Client) { + if _, ok := h.clients[client]; !ok { + h.clients[client] = true + } +} + +// removeClient unregisters client and closes its send channel. +func (h *Hub) removeClient(client *Client) { + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + close(client.send) + } +} + +// broadcast sends message to every client, dropping any whose buffer is full. +func (h *Hub) broadcast(message []byte) { + if logx.IsLevel("DEBUG") { + logx.Debug( + "Broadcast "+string(message), + logx.LogFrom{Primary: "WebSocket"}, + h.clientCount() > 0, + ) + } + + var msg AnnounceMSG + if err := decode.Unmarshal("json", message, &msg); err != nil { + logx.Warn( + "Invalid JSON broadcast to the WebSocket", + logx.LogFrom{Primary: "WebSocket"}, + true, + ) + return + } + + // Non-blocking send; drop any client whose buffer is full. + for client := range h.clients { + select { + case client.send <- message: + default: + close(client.send) + delete(h.clients, client) + } + } +} + +// Run starts the Hub. It owns the clients map; all access happens on this goroutine. func (h *Hub) Run() { for { select { case client := <-h.register: - // Avoid unnecessary writes to the map. - if _, ok := h.clients[client]; !ok { - h.clients[client] = true - } + h.addClient(client) case client := <-h.unregister: - if _, ok := h.clients[client]; ok { - delete(h.clients, client) - close(client.send) - } + h.removeClient(client) case message := <-h.Broadcast: - if logx.IsLevel("DEBUG") { - logx.Debug( - "Broadcast "+string(message), - logx.LogFrom{Primary: "WebSocket"}, - len(h.clients) > 0, - ) - } - - // Validate JSON. - var msg AnnounceMSG - if err := decode.Unmarshal("json", message, &msg); err != nil { - logx.Warn( - "Invalid JSON broadcast to the WebSocket", - logx.LogFrom{Primary: "WebSocket"}, - true, - ) - continue - } - - // Send message to all clients. - for client := range h.clients { - select { - case client.send <- message: - default: - close(client.send) - delete(h.clients, client) - } - } + h.broadcast(message) + case fn := <-h.query: + fn(h.clients) // Read the map on the owning goroutine. } } } diff --git a/web/api/v1/websocket-hub_test.go b/web/api/v1/websocket-hub_test.go index bb7860c6..a75228e1 100644 --- a/web/api/v1/websocket-hub_test.go +++ b/web/api/v1/websocket-hub_test.go @@ -19,12 +19,45 @@ package v1 import ( "fmt" "testing" - "time" "github.com/release-argus/Argus/config/decode" "github.com/release-argus/Argus/internal/test" ) +// hubQuery runs fn against the clients map on the Run goroutine, so a live hub can +// be read race-free. Requires Run() to be active. +func hubQuery[T any](h *Hub, fn func(map[*Client]bool) T) T { + done := make(chan T, 1) + h.query <- func(clients map[*Client]bool) { + done <- fn(clients) + } + return <-done +} + +// hasClient reports whether client is registered (via the Run loop, so safe on a live hub). +func (h *Hub) hasClient(client *Client) bool { + return hubQuery( + h, + func(clients map[*Client]bool) bool { + return clients[client] + }, + ) +} + +// clientList snapshots the registered clients (via the Run loop, so safe on a live hub). +func (h *Hub) clientList() []*Client { + return hubQuery( + h, + func(clients map[*Client]bool) []*Client { + list := make([]*Client, 0, len(clients)) + for client := range clients { + list = append(list, client) + } + return list + }, + ) +} + func TestNewHub(t *testing.T) { // GIVEN: we want a WebSocket Hub. @@ -38,6 +71,7 @@ func TestNewHub(t *testing.T) { {Name: "Broadcast", Got: hub.Broadcast, Want: nil, Mode: test.CompareNotEqual}, {Name: "register", Got: hub.register, Want: nil, Mode: test.CompareNotEqual}, {Name: "unregister", Got: hub.unregister, Want: nil, Mode: test.CompareNotEqual}, + {Name: "query", Got: hub.query, Want: nil, Mode: test.CompareNotEqual}, {Name: "clients", Got: hub.clients, Want: nil, Mode: test.CompareNotEqual}, } if testErr := test.AssertFields(t, fieldTests, prefix, ""); testErr != nil { @@ -45,65 +79,62 @@ func TestNewHub(t *testing.T) { } } -func TestHub_Run__register(t *testing.T) { - // GIVEN: a Hub. +func TestHub_AddClient(t *testing.T) { + // GIVEN: a Hub and two clients (not connected). hub := NewHub() - go hub.Run() - time.Sleep(time.Second) - - // AND: two clients (not connected). client := testClient() otherClient := testClient() - // WHEN: a new client connects. - hub.register <- &client - hub.register <- &otherClient + // WHEN: the clients register. + hub.addClient(&client) + hub.addClient(&otherClient) - // THEN: that client is registered to the Hub. - time.Sleep(time.Second) + // THEN: those client are both registered to the Hub. if !hub.clients[&client] { - t.Errorf("%s\nclient wasn't registered to the Hub", packageName) + t.Errorf("%s\nclient 1 wasn't registered to the Hub with addClient", packageName) + } + if !hub.clients[&otherClient] { + t.Errorf("%s\nclient 2 wasn't registered to the Hub with addClient", packageName) } } -func TestHub_Run__unregister(t *testing.T) { - // GIVEN: a Hub. +func TestHub_RemoveClient(t *testing.T) { + // GIVEN: a Hub with two registered clients. hub := NewHub() - go hub.Run() - time.Sleep(time.Second) - - // AND: a client. client := testClient() otherClient := testClient() - hub.register <- &client - hub.register <- &otherClient - if !hub.clients[&client] { + hub.addClient(&client) + hub.addClient(&otherClient) + if !hub.clients[&client] || !hub.clients[&otherClient] { t.Errorf("%s\nclient wasn't registered to the Hub", packageName) } - // WHEN: that client disconnects. - hub.unregister <- &client - hub.unregister <- &otherClient + // WHEN: the clients disconnect. + hub.removeClient(&client) + hub.removeClient(&otherClient) - // THEN: that client is unregistered to the Hub. + // THEN: those client are unregistered from the Hub. if hub.clients[&client] { t.Errorf( - "%s\nHub.Run() client should have been removed from the Hub after unregister\nclients: %v", - packageName, hub.clients, + "%s\nclient 1 should have been removed from the Hub after removeClient\nremaining clients: %d", + packageName, len(hub.clients), + ) + } + if hub.clients[&otherClient] { + t.Errorf( + "%s\nclient 2 should have been removed from the Hub after removeClient\nremaining clients: %d", + packageName, len(hub.clients), ) } } -func TestHub_Run__broadcast(t *testing.T) { - // GIVEN: a Hub. +func TestHub_Broadcast(t *testing.T) { + prefix := fmt.Sprintf("%s\nHub.broadcast()", packageName) + + // GIVEN: a Hub with a registered client. client := testClient() hub := client.hub - go hub.Run() - - // AND: a client. - time.Sleep(time.Second) - hub.register <- &client - time.Sleep(2 * time.Second) + hub.addClient(&client) // AND: a valid message. msg := AnnounceMSG{ @@ -112,9 +143,14 @@ func TestHub_Run__broadcast(t *testing.T) { } // WHEN: that message is broadcast. - data, _ := decode.Marshal("json", msg) - hub.Broadcast <- data - time.Sleep(time.Second) + data, err := decode.Marshal("json", msg) + if err != nil { + t.Fatalf( + "%s failed to marshal broadcast message: %v", + prefix, err, + ) + } + hub.broadcast(data) // THEN: that message is broadcast to the client. got := <-client.send @@ -122,26 +158,23 @@ func TestHub_Run__broadcast(t *testing.T) { _ = decode.Unmarshal("json", got, &gotMsg) if gotMsg != msg { t.Errorf( - "%s\nHub.Run() message sent to Broadcast channel should have been received by the client channel\ngot: %v\nwant: %v", - packageName, gotMsg, msg, + "%s message should have been received by the client channel\ngot: %v\nwant: %v", + prefix, gotMsg, msg, ) } } -func TestHub_Run__broadcast_allClients(t *testing.T) { - // GIVEN: a Hub. - hub := NewHub() - go hub.Run() - time.Sleep(time.Second) +func TestHub_Broadcast_allClients(t *testing.T) { + prefix := fmt.Sprintf("%s\nHub.broadcast()", packageName) - // AND: multiple clients. + // GIVEN: a Hub with multiple registered clients. + hub := NewHub() clientA := testClient() clientB := testClient() clientC := testClient() - hub.register <- &clientA - hub.register <- &clientB - hub.register <- &clientC - time.Sleep(2 * time.Second) + hub.addClient(&clientA) + hub.addClient(&clientB) + hub.addClient(&clientC) // AND: a valid message. msg := AnnounceMSG{ @@ -150,14 +183,14 @@ func TestHub_Run__broadcast_allClients(t *testing.T) { } data, err := decode.Marshal("json", msg) if err != nil { - t.Fatalf("%s\nHub.Run() failed to marshal broadcast message: %v", packageName, err) + t.Fatalf( + "%s failed to marshal broadcast message: %v", + prefix, err, + ) } // WHEN: that message is broadcast. - hub.Broadcast <- data - time.Sleep(time.Second) - - prefix := fmt.Sprintf("%s\nHub.Run()", packageName) + hub.broadcast(data) // THEN: every registered client receives the message. for name, client := range map[string]*Client{ @@ -184,22 +217,19 @@ func TestHub_Run__broadcast_allClients(t *testing.T) { } } -func TestHub_Run__broadcast_dropsFullClient(t *testing.T) { - // GIVEN: a hub. - hub := NewHub() - go hub.Run() - time.Sleep(time.Second) +func TestHub_Broadcast__dropsFullClient(t *testing.T) { + prefix := fmt.Sprintf("%s\nHub.broadcast()", packageName) - // AND: a client with a full outbound buffer and another with capacity. + // GIVEN: a hub with a client whose outbound buffer is full and another with capacity. + hub := NewHub() slowClient := Client{ ip: "1.1.1.1", send: make(chan []byte, 1), } slowClient.send <- []byte(`{"type":"test"}`) readyClient := testClient() - hub.register <- &slowClient - hub.register <- &readyClient - time.Sleep(2 * time.Second) + hub.addClient(&slowClient) + hub.addClient(&readyClient) // AND: a valid message. msg := AnnounceMSG{ @@ -208,14 +238,14 @@ func TestHub_Run__broadcast_dropsFullClient(t *testing.T) { } data, err := decode.Marshal("json", msg) if err != nil { - t.Fatalf("%s\nHub.Run() failed to marshal broadcast message: %v", packageName, err) + t.Fatalf( + "%s failed to marshal broadcast message: %v", + prefix, err, + ) } // WHEN: that message is broadcast. - hub.Broadcast <- data - time.Sleep(time.Second) - - prefix := fmt.Sprintf("%s\nHub.Run()", packageName) + hub.broadcast(data) // THEN: the slow client is removed from the Hub. if hub.clients[&slowClient] { @@ -252,31 +282,25 @@ func TestHub_Run__broadcast_dropsFullClient(t *testing.T) { } } -func TestHub_Run__broadcast_invalid(t *testing.T) { - // GIVEN: a Hub. +func TestHub_Broadcast__invalid(t *testing.T) { + // GIVEN: a Hub with a registered Client. client := testClient() hub := client.hub - go hub.Run() - time.Sleep(time.Second) - - // AND: a Client. - hub.register <- &client - time.Sleep(2 * time.Second) + hub.addClient(&client) // AND: an invalid message. msg := []byte("key: value\nkey: value") // WHEN: that message is broadcast. data, _ := decode.Marshal("json", msg) - hub.Broadcast <- data - time.Sleep(time.Second) + hub.broadcast(data) // THEN: that message is NOT sent to the client. got := len(client.send) want := 0 if got != want { t.Errorf( - "%s\nHub.Run() message sent to the Broadcast channel should have failed Unmarshal and not been sent\n"+ + "%s\nHub.broadcast() message should have failed Unmarshal and not been sent\n"+ "got: %d\nwant: %d", packageName, got, want, ) diff --git a/web/ui/package-lock.json b/web/ui/package-lock.json index 5df0ff72..c01d4308 100644 --- a/web/ui/package-lock.json +++ b/web/ui/package-lock.json @@ -552,9 +552,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.0.tgz", - "integrity": "sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.1.tgz", + "integrity": "sha512-IXWLCxKmae+rI7LOHS1B3EbVisQ6GRAWbhN9msa6KjNCyFWrvKZWR4oUdinaNssrV852OrSHuSPa95h1GPJc7Q==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -568,20 +568,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.5.0", - "@biomejs/cli-darwin-x64": "2.5.0", - "@biomejs/cli-linux-arm64": "2.5.0", - "@biomejs/cli-linux-arm64-musl": "2.5.0", - "@biomejs/cli-linux-x64": "2.5.0", - "@biomejs/cli-linux-x64-musl": "2.5.0", - "@biomejs/cli-win32-arm64": "2.5.0", - "@biomejs/cli-win32-x64": "2.5.0" + "@biomejs/cli-darwin-arm64": "2.5.1", + "@biomejs/cli-darwin-x64": "2.5.1", + "@biomejs/cli-linux-arm64": "2.5.1", + "@biomejs/cli-linux-arm64-musl": "2.5.1", + "@biomejs/cli-linux-x64": "2.5.1", + "@biomejs/cli-linux-x64-musl": "2.5.1", + "@biomejs/cli-win32-arm64": "2.5.1", + "@biomejs/cli-win32-x64": "2.5.1" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.0.tgz", - "integrity": "sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-npqDzvqv7vFaWRiNN1Te71siRgPaqS9MpqgYCdP/CrUbkJ7ApezaeaKjueKHRN/JH/6lRjJQAHi8acQDCAz22w==", "cpu": [ "arm64" ], @@ -596,9 +596,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.0.tgz", - "integrity": "sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.1.tgz", + "integrity": "sha512-RgwTqPAM8g2tn1j+b5oRjF/DbSBX8a4gwojtuG9XuhfK7GgomvZ9+T+tqjXiVbjLEeGJOoL6VEk8mvRTVeSybw==", "cpu": [ "x64" ], @@ -613,9 +613,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.0.tgz", - "integrity": "sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.1.tgz", + "integrity": "sha512-yhV35CzZh38VyMvTEXi3JTjxZBs++oCKK9KG8vB6VI5+uvQvZNR3BFWEKKzuOmx9DJJj7sQpZ4LQJcmbGTs3+Q==", "cpu": [ "arm64" ], @@ -633,9 +633,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.0.tgz", - "integrity": "sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-WMcvMLgByyTqVxGlq918NBBYliq9FRR9GAQVETHb+VjGVqXCZFfHlZHC1FX4ibuYY/Hg6TJE3rHU0xVrdJXNRw==", "cpu": [ "arm64" ], @@ -653,9 +653,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.0.tgz", - "integrity": "sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.1.tgz", + "integrity": "sha512-J/7uHSX7NfoYDI7HijAkd8lnQIOrRb2W7j3X+tw4R+N5ExvXGsyXFiGdQcfcxfOmNQmZVSQOCDk757fwpzqQcg==", "cpu": [ "x64" ], @@ -673,9 +673,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.0.tgz", - "integrity": "sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-ANTowtlLmPYm5yeMckWY8Xzb9Ix+JJP3tgHR/n6xRj1VWyIzzWtfRfih9hv9VmClwadpBvZduISZIbBsIlYG3A==", "cpu": [ "x64" ], @@ -693,9 +693,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.0.tgz", - "integrity": "sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.1.tgz", + "integrity": "sha512-zgXnKNgWPC4iPF7Y1lR3STUeCUuZRpD6IiOrC7TZTlh0Lx6FiVUT05myuMQHQ9D+1cc7uyMldi4forE6lp0ivQ==", "cpu": [ "arm64" ], @@ -710,9 +710,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.0.tgz", - "integrity": "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.1.tgz", + "integrity": "sha512-6uxpR9hvaglANkZemeSiN/FhYgkGasrEGn267eXIWvjrjJ2LhDlk251IhjVJq6MXzkV2/bcXwLwSroLyPtqRZg==", "cpu": [ "x64" ], @@ -780,20 +780,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, "dependencies": { @@ -801,9 +801,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "license": "MIT", "optional": true, "dependencies": { @@ -1175,13 +1175,13 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -1193,14 +1193,30 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", @@ -2704,9 +2720,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", "cpu": [ "arm64" ], @@ -2720,9 +2736,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", "cpu": [ "arm64" ], @@ -2736,9 +2752,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", "cpu": [ "x64" ], @@ -2752,9 +2768,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", "cpu": [ "x64" ], @@ -2768,9 +2784,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", "cpu": [ "arm" ], @@ -2784,9 +2800,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", "cpu": [ "arm64" ], @@ -2803,9 +2819,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", "cpu": [ "arm64" ], @@ -2822,9 +2838,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", "cpu": [ "ppc64" ], @@ -2841,9 +2857,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", "cpu": [ "s390x" ], @@ -2860,9 +2876,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", "cpu": [ "x64" ], @@ -2879,9 +2895,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", "cpu": [ "x64" ], @@ -2898,9 +2914,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", "cpu": [ "arm64" ], @@ -2914,27 +2930,27 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", "cpu": [ "wasm32" ], "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", "cpu": [ "arm64" ], @@ -2948,9 +2964,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", "cpu": [ "x64" ], @@ -3075,9 +3091,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3095,9 +3108,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3115,9 +3125,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3135,9 +3142,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3155,9 +3159,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3175,9 +3176,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3256,9 +3254,9 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", - "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", @@ -3267,36 +3265,36 @@ "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.1" + "tailwindcss": "4.3.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", - "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-x64": "4.3.1", - "@tailwindcss/oxide-freebsd-x64": "4.3.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-x64-musl": "4.3.1", - "@tailwindcss/oxide-wasm32-wasi": "4.3.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", - "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", "cpu": [ "arm64" ], @@ -3310,9 +3308,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", - "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", "cpu": [ "arm64" ], @@ -3326,9 +3324,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", - "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", "cpu": [ "x64" ], @@ -3342,9 +3340,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", - "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", "cpu": [ "x64" ], @@ -3358,9 +3356,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", - "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", "cpu": [ "arm" ], @@ -3374,9 +3372,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", - "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", "cpu": [ "arm64" ], @@ -3393,9 +3391,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", - "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", "cpu": [ "arm64" ], @@ -3412,9 +3410,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", - "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", "cpu": [ "x64" ], @@ -3431,9 +3429,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", - "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", "cpu": [ "x64" ], @@ -3450,9 +3448,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", - "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -3467,9 +3465,9 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" @@ -3478,70 +3476,10 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", - "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", "cpu": [ "arm64" ], @@ -3555,9 +3493,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", - "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", "cpu": [ "x64" ], @@ -3571,23 +3509,23 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", - "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.1", - "@tailwindcss/oxide": "4.3.1", - "tailwindcss": "4.3.1" + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "node_modules/@tanstack/query-core": { - "version": "5.101.0", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", - "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", "license": "MIT", "funding": { "type": "github", @@ -3595,9 +3533,9 @@ } }, "node_modules/@tanstack/query-devtools": { - "version": "5.101.0", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.0.tgz", - "integrity": "sha512-MVqw17k08RQtGGLEL654+dX/btbX9p/8WjkznO//zusLTMaObxi3Q+MoFwGVkC9K3tqjn8qrrNhJevXx4fJTeQ==", + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.2.tgz", + "integrity": "sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==", "dev": true, "license": "MIT", "funding": { @@ -3606,12 +3544,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.101.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", - "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.101.0" + "@tanstack/query-core": "5.101.2" }, "funding": { "type": "github", @@ -3622,20 +3560,20 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.101.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.0.tgz", - "integrity": "sha512-cpZA0+WqKXwrwMfiWZEGGF6QrIWVQFbhBtxqDF5sQsAfrFf47HIE6fiPbQU3wyAUEN2+7UNqLCQe7oG6m3f93w==", + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.2.tgz", + "integrity": "sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.101.0" + "@tanstack/query-devtools": "5.101.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query": "^5.101.2", "react": "^18 || ^19" } }, @@ -3673,9 +3611,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "license": "MIT", "optional": true, "dependencies": { @@ -3698,9 +3636,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", - "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", + "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4200,9 +4138,9 @@ } }, "node_modules/globals": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", - "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", "dev": true, "license": "MIT", "engines": { @@ -4212,13 +4150,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "dev": true, - "license": "MIT" - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4479,9 +4410,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4502,9 +4430,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4525,9 +4450,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4548,9 +4470,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4640,9 +4559,9 @@ } }, "node_modules/lucide-react": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", - "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.22.0.tgz", + "integrity": "sha512-c9o3l0PiNcgOQDW4F31BEYHudE7kgxVt3o30qMl36ZPwTxXlGB4QnLilhERvVM4uh/pl5MDyY1/gzZSYcHDtBg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -4791,9 +4710,9 @@ "license": "MIT" }, "node_modules/partysocket": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/partysocket/-/partysocket-1.2.0.tgz", - "integrity": "sha512-xgXql4N0b3umN263lHkrZMvvtYC906h4YjY8l63LNcF0x1bfJmQtZLQGqNQWzFHAxLns/6h6/rjdLW4EdYqQwA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/partysocket/-/partysocket-1.3.0.tgz", + "integrity": "sha512-1zToNyolZFK/7nuAw/K2bZrNzFqaZyRoCEkS+9vG6WSC5ikrN6qWRe96q6ImU51uptz2r+dAwSkwhJVdQi4LiA==", "license": "MIT", "dependencies": { "event-target-polyfill": "^0.0.4" @@ -4840,6 +4759,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -5047,9 +5013,9 @@ } }, "node_modules/react-router": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", - "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -5069,12 +5035,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", - "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", "license": "MIT", "dependencies": { - "react-router": "7.18.0" + "react-router": "7.18.1" }, "engines": { "node": ">=20.0.0" @@ -5174,12 +5140,12 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5189,21 +5155,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" } }, "node_modules/scheduler": { @@ -5288,9 +5254,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", - "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", "license": "MIT" }, "node_modules/tapable": { @@ -5449,15 +5415,15 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", - "rolldown": "1.0.3", + "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "bin": { @@ -5474,7 +5440,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5525,43 +5491,6 @@ } } }, - "node_modules/vite-tsconfig-paths": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.1.1.tgz", - "integrity": "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "globrex": "^0.1.2", - "tsconfck": "^3.0.3" - }, - "peerDependencies": { - "vite": "*" - } - }, - "node_modules/vite-tsconfig-paths/node_modules/tsconfck": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", - "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", - "deprecated": "unmaintained", - "dev": true, - "license": "MIT", - "bin": { - "tsconfck": "bin/tsconfck.js" - }, - "engines": { - "node": "^18 || >=20" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -5609,44 +5538,45 @@ "@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-toggle": "^1.1.12", "@radix-ui/react-toggle-group": "^1.1.13", - "@tailwindcss/vite": "^4.3.1", - "@tanstack/react-query": "^5.101.0", + "@tailwindcss/vite": "^4.3.2", + "@tanstack/react-query": "^5.101.2", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.4.0", - "lucide-react": "^1.21.0", + "lucide-react": "^1.22.0", "next-themes": "^0.4.6", - "partysocket": "^1.2.0", + "partysocket": "^1.3.0", "radix-ui": "^1.6.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-hook-form": "^7.80.0", - "react-router-dom": "^7.18.0", + "react-router-dom": "^7.18.1", "react-select": "^5.10.2", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tailwindcss": "^4.3.1", + "tailwindcss": "^4.3.2", "yaml": "^2.9.0", "zod": "^4.4.3" }, "devDependencies": { + "@babel/plugin-syntax-jsx": "8.0.1", "@babel/preset-react": "8.0.1", "@babel/preset-typescript": "8.0.1", - "@biomejs/biome": "2.5.0", - "@tanstack/react-query-devtools": "5.101.0", - "@types/node": "26.0.0", + "@biomejs/biome": "2.5.1", + "@playwright/test": "^1.61.1", + "@tanstack/react-query-devtools": "5.101.2", + "@types/node": "26.0.1", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitejs/plugin-react-swc": "4.3.1", "babel-plugin-react-compiler": "1.0.0", - "globals": "17.6.0", + "globals": "17.7.0", "tw-animate-css": "1.4.0", "typescript": "6.0.3", - "vite": "8.0.16", - "vite-plugin-babel": "1.7.3", - "vite-tsconfig-paths": "6.1.1" + "vite": "8.1.0", + "vite-plugin-babel": "1.7.3" } }, "react-app/node_modules/@babel/code-frame": { diff --git a/web/ui/react-app/.gitignore b/web/ui/react-app/.gitignore new file mode 100644 index 00000000..e2caba9e --- /dev/null +++ b/web/ui/react-app/.gitignore @@ -0,0 +1,12 @@ +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/results.json +/playwright/.cache/ +/playwright/.auth/ +/tests/playwright-test-config.yml +/tests/playwright-tests.db +/tests/playwright-argus.pid +/tests/playwright-test.log diff --git a/web/ui/react-app/package.json b/web/ui/react-app/package.json index 4b65598b..26f2f518 100644 --- a/web/ui/react-app/package.json +++ b/web/ui/react-app/package.json @@ -23,45 +23,46 @@ "@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-toggle": "^1.1.12", "@radix-ui/react-toggle-group": "^1.1.13", - "@tailwindcss/vite": "^4.3.1", - "@tanstack/react-query": "^5.101.0", + "@tailwindcss/vite": "^4.3.2", + "@tanstack/react-query": "^5.101.2", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.4.0", - "lucide-react": "^1.21.0", + "lucide-react": "^1.22.0", "next-themes": "^0.4.6", - "partysocket": "^1.2.0", + "partysocket": "^1.3.0", "radix-ui": "^1.6.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-hook-form": "^7.80.0", - "react-router-dom": "^7.18.0", + "react-router-dom": "^7.18.1", "react-select": "^5.10.2", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tailwindcss": "^4.3.1", + "tailwindcss": "^4.3.2", "yaml": "^2.9.0", "zod": "^4.4.3" }, "description": "Automated checks for new software releases", "devDependencies": { + "@babel/plugin-syntax-jsx": "8.0.1", "@babel/preset-react": "8.0.1", "@babel/preset-typescript": "8.0.1", - "@biomejs/biome": "2.5.0", - "@tanstack/react-query-devtools": "5.101.0", - "@types/node": "26.0.0", + "@biomejs/biome": "2.5.1", + "@playwright/test": "^1.61.1", + "@tanstack/react-query-devtools": "5.101.2", + "@types/node": "26.0.1", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitejs/plugin-react-swc": "4.3.1", "babel-plugin-react-compiler": "1.0.0", - "globals": "17.6.0", + "globals": "17.7.0", "tw-animate-css": "1.4.0", "typescript": "6.0.3", - "vite": "8.0.16", - "vite-plugin-babel": "1.7.3", - "vite-tsconfig-paths": "6.1.1" + "vite": "8.1.0", + "vite-plugin-babel": "1.7.3" }, "homepage": "https://release-argus.io", "name": "argus", @@ -71,7 +72,8 @@ "build": "tsc -b && vite build", "lint": "biome lint .", "preview": "vite preview", - "start": "vite --host" + "start": "vite --host", + "typecheck:e2e": "tsc -p tsconfig.playwright.json" }, "type": "module", "version": "0.29.4" diff --git a/web/ui/react-app/playwright.config.ts b/web/ui/react-app/playwright.config.ts new file mode 100644 index 00000000..c0d9b156 --- /dev/null +++ b/web/ui/react-app/playwright.config.ts @@ -0,0 +1,99 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * Specs that create/delete services must run one-at-a-time per browser. + * They suffix their service IDs per project to avoid cross-browser collisions, + * but they all drive a single shared backend and a dashboard that re-renders on + * every service add/remove. Running them concurrently makes the stateful flows + * (modal create/save, the ordering drag) race that churn and time out, so each + * `-mutating` project below caps itself to one worker. + */ +const SERIAL_SPECS = [ + 'create-service.spec.ts', + 'webhook.spec.ts', + 'service-secret-inheritance.spec.ts', + 'service-ordering.spec.ts', +]; + +const BROWSERS = { + chromium: devices['Desktop Chrome'], + firefox: devices['Desktop Firefox'], + webkit: devices['Desktop Safari'], +}; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Run tests in files in parallel */ + fullyParallel: true, + + /* Hard ceiling on the whole run so a stuck backend (an unreachable endpoint + * or an exhausted API rate limit) can't hang the suite for very long. */ + globalTimeout: process.env.CI ? 30 * 60_000 : 15 * 60_000, + + /* One project per browser, split into a parallel-safe project and a + * single-worker `-mutating` project. */ + projects: [ + ...Object.entries(BROWSERS).flatMap(([name, use]) => [ + { + name, + testIgnore: SERIAL_SPECS, + use, + }, + { + name: `${name}-mutating`, + testMatch: SERIAL_SPECS, + use, + /* Mutating specs run one-at-a-time per browser. */ + workers: 1, + }, + ]), + + /* Mobile viewports run the read-only/validation specs only, to avoid + * cross-project contention. Skip navigation since navbar differs on mobile */ + { + name: 'mobile--google-chrome', + testIgnore: [...SERIAL_SPECS, 'navigation.spec.ts'], + use: devices['Pixel 8'], + }, + { + name: 'mobile--safari', + testIgnore: [...SERIAL_SPECS, 'navigation.spec.ts'], + use: devices['iPhone 16'], + }, + ], + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI + ? [['github'], ['html'], ['json', { outputFile: 'results.json' }]] + : [['html']], + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + testDir: './tests', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Bound individual interactions/navigations so a single stuck action + * fails fast rather than riding out the (tripled, via `test.slow()`) + * test timeout. */ + actionTimeout: 15_000, + /* Base URL to use in actions like `await page.goto('')`. */ + baseURL: process.env.BASE_URL ?? 'http://localhost:8080', + navigationTimeout: 30_000, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + /* Total worker pool. The `-mutating` projects cap themselves to 1 worker + * each regardless of this value; everything else uses the full pool. */ + workers: process.env.CI ? '50%' : undefined, +}); diff --git a/web/ui/react-app/src/components/approvals/service.tsx b/web/ui/react-app/src/components/approvals/service.tsx index e34ad5b1..29fef1fb 100644 --- a/web/ui/react-app/src/components/approvals/service.tsx +++ b/web/ui/react-app/src/components/approvals/service.tsx @@ -110,6 +110,7 @@ const Service: FC = ({ id, editable = false }) => { : updateStatus.className, )} data-service-id={id} + data-update-available={updateStatus.warning && data?.active !== false} key={data?.id} ref={setNodeRef} style={dragStyle} diff --git a/web/ui/react-app/src/components/generic/field-check.tsx b/web/ui/react-app/src/components/generic/field-check.tsx index 35f7e339..949c9498 100644 --- a/web/ui/react-app/src/components/generic/field-check.tsx +++ b/web/ui/react-app/src/components/generic/field-check.tsx @@ -86,6 +86,7 @@ const FieldCheck: FC = ({ aria-invalid={fieldState.invalid} checked={field.value} className={cn('min-h-9 w-full', checkboxClassName)} + id={name} name={field.name} onCheckedChange={field.onChange} /> diff --git a/web/ui/react-app/src/components/generic/field-key-val-map.tsx b/web/ui/react-app/src/components/generic/field-key-val-map.tsx index 5edb7c8e..d22bcee4 100644 --- a/web/ui/react-app/src/components/generic/field-key-val-map.tsx +++ b/web/ui/react-app/src/components/generic/field-key-val-map.tsx @@ -137,7 +137,7 @@ const FieldKeyValMap: FC = ({ placeholders={placeholders} removeMe={ // Disable 'remove' if one item, and it matches the defaults. - fieldValues.length === 1 ? removeLast() : removeItem(index) + fieldValues?.length === 1 ? removeLast() : removeItem(index) } /> ))} diff --git a/web/ui/react-app/src/components/generic/field-key-val.tsx b/web/ui/react-app/src/components/generic/field-key-val.tsx index e07f5fc5..e8881321 100644 --- a/web/ui/react-app/src/components/generic/field-key-val.tsx +++ b/web/ui/react-app/src/components/generic/field-key-val.tsx @@ -3,7 +3,6 @@ import type { FC } from 'react'; import { FieldText } from '@/components/generic/field'; import type { HeaderPlaceholders } from '@/components/generic/field-shared'; import { Button } from '@/components/ui/button'; -import { FieldGroup } from '@/components/ui/field'; import { cn } from '@/lib/utils'; import type { Header } from '@/utils/api/types/config/shared'; @@ -53,7 +52,7 @@ const FieldKeyVal: FC = ({ - = ({ placeholder={placeholders?.value ?? 'e.g. value'} required /> - + ); }; diff --git a/web/ui/react-app/src/components/generic/field-list.tsx b/web/ui/react-app/src/components/generic/field-list.tsx index 96e3b3f5..4772d230 100644 --- a/web/ui/react-app/src/components/generic/field-list.tsx +++ b/web/ui/react-app/src/components/generic/field-list.tsx @@ -5,7 +5,7 @@ import { FieldLabel, FieldText } from '@/components/generic/field'; import type { TooltipWithAriaProps } from '@/components/generic/tooltip'; import { Button } from '@/components/ui/button'; import { ButtonGroup } from '@/components/ui/button-group'; -import { FieldGroup, FieldSet } from '@/components/ui/field'; +import { FieldSet } from '@/components/ui/field'; import type { StringFieldArray } from '@/types/util'; import { isEmptyArray } from '@/utils'; import { isUsingDefaults } from '@/utils/api/types/config-edit/validators'; @@ -98,8 +98,8 @@ const FieldList: FC = ({ }, [fields.length, usingDefaults]); return ( -
- +
+
- - +
+
{fields.map(({ id }, index) => ( ))} - +
); }; diff --git a/web/ui/react-app/src/components/modals/action-release/item.tsx b/web/ui/react-app/src/components/modals/action-release/item.tsx index 5ae84338..86cf081b 100644 --- a/web/ui/react-app/src/components/modals/action-release/item.tsx +++ b/web/ui/react-app/src/components/modals/action-release/item.tsx @@ -142,7 +142,7 @@ export const Item: FC = ({

{`Can resend ${formatRelative(new Date(next_runnable), new Date())}`}

} > - + )} {!sending && failed !== undefined && } diff --git a/web/ui/react-app/src/components/modals/service-edit/command.tsx b/web/ui/react-app/src/components/modals/service-edit/command.tsx index c5830a1f..b245a75a 100644 --- a/web/ui/react-app/src/components/modals/service-edit/command.tsx +++ b/web/ui/react-app/src/components/modals/service-edit/command.tsx @@ -4,7 +4,6 @@ import { useFieldArray } from 'react-hook-form'; import { FieldText } from '@/components/generic/field'; import { Button } from '@/components/ui/button'; import { ButtonGroup } from '@/components/ui/button-group'; -import { FieldGroup } from '@/components/ui/field'; import { isEmptyArray } from '@/utils'; import type { CommandSchema } from '@/utils/api/types/config-edit/command/schemas'; @@ -51,7 +50,7 @@ const Command: FC = ({ name, defaults, removeMe }) => { return (
- +
{fields.map(({ id }, argIndex) => ( = ({ name, defaults, removeMe }) => { required /> ))} - +
{removeMe && ( diff --git a/web/ui/react-app/src/components/modals/service-edit/latest-version-require.tsx b/web/ui/react-app/src/components/modals/service-edit/latest-version-require.tsx index 2b62d05d..255cd48e 100644 --- a/web/ui/react-app/src/components/modals/service-edit/latest-version-require.tsx +++ b/web/ui/react-app/src/components/modals/service-edit/latest-version-require.tsx @@ -10,7 +10,7 @@ import { AccordionItem, AccordionTrigger, } from '@/components/ui/accordion'; -import { FieldGroup, FieldLegend, FieldSet } from '@/components/ui/field'; +import { FieldLegend, FieldSet } from '@/components/ui/field'; import { Separator } from '@/components/ui/separator'; import { useSchemaContext } from '@/contexts/service-edit-zod-type'; import type { NonNull } from '@/types/util'; @@ -124,7 +124,7 @@ const EditServiceLatestVersionRequire = () => { }} /> - +
{ }} /> - +
diff --git a/web/ui/react-app/src/components/modals/service-edit/service.tsx b/web/ui/react-app/src/components/modals/service-edit/service.tsx index 248797f2..120c9671 100644 --- a/web/ui/react-app/src/components/modals/service-edit/service.tsx +++ b/web/ui/react-app/src/components/modals/service-edit/service.tsx @@ -8,7 +8,6 @@ import EditServiceOptions from '@/components/modals/service-edit/options'; import EditServiceRoot from '@/components/modals/service-edit/root'; import EditServiceWebHooks from '@/components/modals/service-edit/webhooks'; import { Accordion } from '@/components/ui/accordion'; -import { FieldSet } from '@/components/ui/field'; type EditServiceProps = { /* Indicates whether the modal shows a loading state. */ @@ -24,7 +23,7 @@ type EditServiceProps = { */ const EditService: FC = ({ loading }) => { return ( -
+
@@ -37,7 +36,7 @@ const EditService: FC = ({ loading }) => {
-
+
); }; diff --git a/web/ui/react-app/src/components/ui/field.tsx b/web/ui/react-app/src/components/ui/field.tsx index 447c4418..037a8bf4 100644 --- a/web/ui/react-app/src/components/ui/field.tsx +++ b/web/ui/react-app/src/components/ui/field.tsx @@ -4,15 +4,21 @@ import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; import { cn } from '@/lib/utils'; -function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) { +// Renders a `
` rather than a `
`: Chromium does not +// propagate `subgrid` track sizing through a `
` grid container (the +// fieldset sizes correctly but its subgrid child collapses to min-content), and +// the service-edit modal nests `grid grid-cols-subgrid` field-sets several +// levels deep. A `
` carries the same grid classes without that limitation. +function FieldSet({ className, ...props }: React.ComponentProps<'div'>) { return ( -
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3', className, )} data-slot="field-set" + role="group" {...props} /> ); diff --git a/web/ui/react-app/src/hooks/use-sortable-services.ts b/web/ui/react-app/src/hooks/use-sortable-services.ts index b41e4a04..aefb1714 100644 --- a/web/ui/react-app/src/hooks/use-sortable-services.ts +++ b/web/ui/react-app/src/hooks/use-sortable-services.ts @@ -12,7 +12,6 @@ import { toast } from 'sonner'; import { useServiceOrder } from '@/hooks/use-service-order'; import { QUERY_KEYS } from '@/lib/query-keys'; import { mapRequest } from '@/utils/api/types/api-request-handler'; -import diffLists from '@/utils/diff-lists'; /** * Manage sortable services. @@ -35,14 +34,27 @@ export const useSortableServices = () => { // biome-ignore lint/correctness/useExhaustiveDependencies: orderData covers serverOrder. useEffect(() => { - // Initialise from server order once available. - if (haveOrderData) { - // If a service was added/removed, reset the order to the server order. - const diffServiceIDs = diffLists({ listA: order, listB: serverOrder }); - const shouldApplyOrder = !hasOrderChanged || diffServiceIDs; - - if (order.length === 0 || shouldApplyOrder) setOrder(serverOrder); - } + // React to server-order changes (add/remove/reorder from elsewhere). + if (!haveOrderData) return; + + setOrder((current) => { + // First load - adopt the server order as-is. + if (current.length === 0) return serverOrder; + + // Reconcile membership without discarding an in-progress reorder: keep + // the user's order, drop ids the server no longer has, append new ids. + const serverSet = new Set(serverOrder); + const kept = current.filter((id) => serverSet.has(id)); + const keptSet = new Set(kept); + const added = serverOrder.filter((id) => !keptSet.has(id)); + const merged = [...kept, ...added]; + + // Skip a no-op update to avoid a re-render. + const unchanged = + merged.length === current.length && + merged.every((id, index) => id === current[index]); + return unchanged ? current : merged; + }); }, [serverOrder]); const sensors = useSensors( diff --git a/web/ui/react-app/src/utils/api/types/config-edit/service/form/builder--deployed-version.ts b/web/ui/react-app/src/utils/api/types/config-edit/service/form/builder--deployed-version.ts index 5ec77079..84bd3490 100644 --- a/web/ui/react-app/src/utils/api/types/config-edit/service/form/builder--deployed-version.ts +++ b/web/ui/react-app/src/utils/api/types/config-edit/service/form/builder--deployed-version.ts @@ -2,10 +2,12 @@ import { z } from 'zod'; import { DEPLOYED_VERSION_LOOKUP_TYPE, type DeployedVersionLookup, - deployedVersionLookupTypeOptions, + type DeployedVersionLookupType, + type DeployedVersionLookupURL, } from '@/utils/api/types/config/service/deployed-version'; import { type deployedVersionLookupSchema, + deployedVersionLookupSchemaDefault, deployedVersionManualSchema, deployedVersionURLSchema, isDeployedVersionType, @@ -32,12 +34,18 @@ export const buildDeployedVersionLookupSchemaWithFallbacks = ( data?: DeployedVersionLookup, defaults?: DeployedVersionLookup, hardDefaults?: DeployedVersionLookup, -): BuilderResponse => { +): BuilderResponse< + typeof deployedVersionLookupSchema, + typeof deployedVersionLookupSchemaDefault +> => { const path = 'deployed_version'; const combinedDefaults = applyDefaultsRecursive( defaults ?? null, hardDefaults, ); + const defaultType = isDeployedVersionType(combinedDefaults.type) + ? combinedDefaults.type + : undefined; // Manual schema. const dvManualSchema = deployedVersionManualSchema; @@ -82,25 +90,26 @@ export const buildDeployedVersionLookupSchemaWithFallbacks = ( ]); const schema = z.discriminatedUnion('type', [dvManualSchema, dvURLSchema]); - // Initial type/method. - const fallbackType = Object.values(deployedVersionLookupTypeOptions)[1].value; + // Initial type. const schemaDataType = isDeployedVersionType(data?.type) ? data.type - : fallbackType; + : defaultType; // Initial schema data. - const fallbackData = { - headers: headersSchemaData, + const fallbackData: Partial> = { type: schemaDataType, }; - if (!isDeployedVersionType(fallbackData.type)) - fallbackData.type = fallbackType; + // Type-specific schema data. + if (schemaDataType === DEPLOYED_VERSION_LOOKUP_TYPE.URL.value) { + (fallbackData as DeployedVersionLookupURL).headers = headersSchemaData; + } const schemaData = safeParse({ data: { allow_invalid_certs: null, ...data, ...fallbackData, }, - fallback: fallbackData, + // biome-ignore lint/suspicious/noExplicitAny: couldn't get the discriminated union type to work as fallback. + fallback: fallbackData as any, path: path, schema: schemaRaw, }); @@ -114,10 +123,11 @@ export const buildDeployedVersionLookupSchemaWithFallbacks = ( }, fallback: { headers: headersSchemaDataDefaults, - type: fallbackData.type, + method: null, + type: fallbackData.type as DeployedVersionLookupType, }, path: `${path} (defaults)`, - schema: schemaRaw, + schema: deployedVersionLookupSchemaDefault, }); return { diff --git a/web/ui/react-app/src/utils/api/types/config-edit/service/form/builder--latest-version.ts b/web/ui/react-app/src/utils/api/types/config-edit/service/form/builder--latest-version.ts index cce46709..0fa6d1c7 100644 --- a/web/ui/react-app/src/utils/api/types/config-edit/service/form/builder--latest-version.ts +++ b/web/ui/react-app/src/utils/api/types/config-edit/service/form/builder--latest-version.ts @@ -10,10 +10,10 @@ import { type LatestVersionLookup, type LatestVersionLookupDefaults, type LatestVersionLookupGitHub, + type LatestVersionLookupType, type LatestVersionLookupURL, type LatestVersionRequire, type LatestVersionRequireDefaults, - latestVersionLookupTypeOptions, type RequireDockerFilterDefaults, type URLCommand, } from '@/utils/api/types/config/service/latest-version'; @@ -34,6 +34,7 @@ import { urlCommandsSchemaWithValidation, } from '@/utils/api/types/config-edit/service/types/latest-version'; import { addZodIssuesToContext } from '@/utils/api/types/config-edit/shared/add-issues'; +import { buildHeadersSchemaWithFallbacks } from '@/utils/api/types/config-edit/shared/header/builder'; import { nullString } from '@/utils/api/types/config-edit/shared/null-string'; import { stringDefault } from '@/utils/api/types/config-edit/shared/preprocess'; import { safeParse } from '@/utils/api/types/config-edit/shared/safeparse'; @@ -123,10 +124,7 @@ export const buildDockerFilterSchemaWithFallbacks = ( typeof dockerFilterSchemaDefaults > => { const path = 'latest_version.require.docker'; - const defaultType = - defaults?.type || - hardDefaults?.type || - LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.DOCKER_HUB.value; + const defaultType = defaults?.type || hardDefaults?.type || undefined; const dockerHubValue = LATEST_VERSION_LOOKUP__REQUIRE_DOCKER_TYPE.DOCKER_HUB.value; @@ -191,11 +189,11 @@ export const buildDockerFilterSchemaWithFallbacks = ( // Add validation for required fields. const schemaFinal = schema.superRefine((arg, ctx) => { const schemaType = arg.type === nullString ? defaultType : arg.type; - const schemaDefaults = combinedDefaults.registry[schemaType]; + const schemaDefaults = schemaType && combinedDefaults.registry[schemaType]; const hasImage = !!arg.image?.trim(); - const hasImageDefaulted = hasImage || !!schemaDefaults.image?.trim(); + const hasImageDefaulted = hasImage || !!schemaDefaults?.image?.trim(); const hasTag = !!arg.tag?.trim(); - const hasTagDefaulted = hasTag || !!schemaDefaults.tag?.trim(); + const hasTagDefaulted = hasTag || !!schemaDefaults?.tag?.trim(); // If we have an image, we must have a tag, and vice versa. if (hasImage !== hasTag && hasImageDefaulted !== hasTagDefaulted) { @@ -207,7 +205,12 @@ export const buildDockerFilterSchemaWithFallbacks = ( } // If we have an image:tag specified and have a username field. - if (hasImageDefaulted && hasTagDefaulted && usernameTypes.has(schemaType)) { + if ( + hasImageDefaulted && + hasTagDefaulted && + schemaType && + usernameTypes.has(schemaType) + ) { type DockerUsernameTyped = z.infer< typeof latestVersionLookupRequireDockerTypeSchemaDockerHub >; @@ -217,7 +220,7 @@ export const buildDockerFilterSchemaWithFallbacks = ( (schemaDefaults as Partial).auth?.username )?.trim(); const hasToken = !!( - arg.auth?.token || schemaDefaults.auth?.token + arg.auth?.token || schemaDefaults?.auth?.token )?.trim(); // We must have a username and token, or neither. @@ -351,15 +354,13 @@ export const buildLatestVersionLookupSchemaWithFallbacks = ( typeof latestVersionLookupSchemaDefault > => { const path = 'latest_version'; - const fallbackType = Object.values(latestVersionLookupTypeOptions)[0].value; const combinedDefaults = applyDefaultsRecursive( defaults ?? null, hardDefaults, - { type: fallbackType }, ); - const typeDefault = isLatestVersionType(combinedDefaults.type) + const defaultType = isLatestVersionType(combinedDefaults.type) ? combinedDefaults.type - : fallbackType; + : undefined; // url_commands. const { @@ -379,6 +380,15 @@ export const buildLatestVersionLookupSchemaWithFallbacks = ( data?.require, combinedDefaults?.require, ); + // headers (URL-type only). + const { + schema: headersSchema, + schemaData: headersSchemaData, + schemaDataDefaults: headersSchemaDataDefaults, + } = buildHeadersSchemaWithFallbacks( + data && 'headers' in data ? data.headers : [], + 'headers' in combinedDefaults ? combinedDefaults.headers : [], + ); // Schemas shared between multiple types. const sharedSchemas = { @@ -389,7 +399,10 @@ export const buildLatestVersionLookupSchemaWithFallbacks = ( // Latest version schema. const schemaRaw = z.discriminatedUnion('type', [ latestVersionLookupSchemaGitHub.extend(sharedSchemas), - latestVersionLookupSchemaURL.extend(sharedSchemas), + latestVersionLookupSchemaURL.extend({ + ...sharedSchemas, + headers: headersSchema, + }), ]); const schema = z.discriminatedUnion('type', [ latestVersionLookupSchemaGitHub.extend({ @@ -403,6 +416,7 @@ export const buildLatestVersionLookupSchemaWithFallbacks = ( }), latestVersionLookupSchemaURL.extend({ ...sharedSchemas, + headers: headersSchema, url: z.string().superRefine((arg, ctx) => { const url = arg || (defaults?.url ?? hardDefaults?.url ?? ''); @@ -412,15 +426,18 @@ export const buildLatestVersionLookupSchemaWithFallbacks = ( }), ]); + // Initial type. + const schemaDataType = isLatestVersionType(data?.type) + ? data.type + : defaultType; + // Initial schema data. const fallbackData: Partial> = { require: requireSchemaData, + type: schemaDataType, url_commands: urlCommandsSchemaData, }; - // Initial schema type. - const lvType = isLatestVersionType(data?.type) ? data.type : typeDefault; - fallbackData.type = lvType; // Type-specific schema data. - if (lvType === LATEST_VERSION_LOOKUP_TYPE.GITHUB.value) { + if (schemaDataType === LATEST_VERSION_LOOKUP_TYPE.GITHUB.value) { const typedLatestVersion = (data ?? {}) as LatestVersionLookupGitHub; (fallbackData as LatestVersionLookupGitHub).use_prerelease = typedLatestVersion.use_prerelease; @@ -429,8 +446,8 @@ export const buildLatestVersionLookupSchemaWithFallbacks = ( const typedLatestVersion = (data ?? {}) as LatestVersionLookupURL; (fallbackData as LatestVersionLookupURL).allow_invalid_certs = typedLatestVersion.allow_invalid_certs; + (fallbackData as LatestVersionLookupURL).headers = headersSchemaData; } - // Initial schema data. const schemaData = safeParse({ data: { url: '', @@ -447,13 +464,15 @@ export const buildLatestVersionLookupSchemaWithFallbacks = ( const schemaDataDefaults = safeParse({ data: { ...combinedDefaults, + headers: headersSchemaDataDefaults, require: requireSchemaDataDefaults, - type: typeDefault, + type: defaultType, url_commands: urlCommandsSchemaDataDefaults, }, fallback: { + headers: headersSchemaDataDefaults, require: requireSchemaDataDefaults, - type: typeDefault, + type: fallbackData.type as LatestVersionLookupType, }, path: path, schema: latestVersionLookupSchemaDefault, diff --git a/web/ui/react-app/src/utils/api/types/config-edit/service/types/latest-version.ts b/web/ui/react-app/src/utils/api/types/config-edit/service/types/latest-version.ts index c032b692..2cc919d8 100644 --- a/web/ui/react-app/src/utils/api/types/config-edit/service/types/latest-version.ts +++ b/web/ui/react-app/src/utils/api/types/config-edit/service/types/latest-version.ts @@ -235,6 +235,7 @@ export const latestVersionLookupSchemaDefault = z .object({ access_token: stringDefault, allow_invalid_certs: z.boolean().nullable().optional(), + headers: headersSchema.optional(), require: latestVersionRequireSchemaDefaults.optional(), type: LatestVersionTypeEnum.nullable().optional(), url: stringDefault, @@ -243,7 +244,7 @@ export const latestVersionLookupSchemaDefault = z }) .optional(); export type LatestVersionLookupSchemaDefault = z.infer< - typeof latestVersionLookupSchema + typeof latestVersionLookupSchemaDefault >; export const latestVersionLookupSchemaOutgoing = z.discriminatedUnion('type', [ diff --git a/web/ui/react-app/src/utils/api/types/config-edit/webhook/form/builder.ts b/web/ui/react-app/src/utils/api/types/config-edit/webhook/form/builder.ts index d2e46ab4..15c9cd19 100644 --- a/web/ui/react-app/src/utils/api/types/config-edit/webhook/form/builder.ts +++ b/web/ui/react-app/src/utils/api/types/config-edit/webhook/form/builder.ts @@ -2,10 +2,9 @@ import { z } from 'zod'; import { isEmptyArray } from '@/utils'; import { isWebHookType, - WEBHOOK_TYPE, type WebHook, type WebHookMap, - webhookTypeOptions, + type WebHookType, } from '@/utils/api/types/config/webhook'; import { buildSuperRefine } from '@/utils/api/types/config-edit/shared/builder--super-refine'; import { @@ -78,10 +77,14 @@ export const buildWebHooksSchemaWithFallbacks = ( hardDefaults?: WebHook, ) => { const path = 'webhook'; - const defaultType = - defaults?.type ?? - hardDefaults?.type ?? - Object.values(webhookTypeOptions)[0].value; + const combinedDefaults = applyDefaultsRecursive( + defaults ?? null, + hardDefaults, + ); + const defaultType = isWebHookType(combinedDefaults.type) + ? combinedDefaults.type + : undefined; + const dataDefaulted = (data ?? []).map((item) => { const main = mains?.[item.name]; const nameLower = item.name.toLowerCase(); @@ -102,31 +105,22 @@ export const buildWebHooksSchemaWithFallbacks = ( fallback: { desired_status_code: '', max_tries: '', - type: defaultType, + type: defaultType as WebHookType, }, path: `${path} (defaults-${item.name})`, schema: webhookSchema, }); }); - const combinedDefaults = applyDefaultsRecursive( - defaults ?? null, - hardDefaults, - { - headers: [], - type: WEBHOOK_TYPE.GITHUB.value, - }, - ); const schemaDataTypeDefaults = safeParse({ data: combinedDefaults, fallback: { desired_status_code: '', max_tries: '', - type: defaultType, + type: defaultType as WebHookType, }, path: `${path} (defaults)`, schema: webhookSchemaDefault, }); - const typeDefault = schemaDataTypeDefaults.type; // Default schema data. const schemaDataDefaults: WebHookSchema[] = (defaultItems ?? []).map( @@ -134,7 +128,7 @@ export const buildWebHooksSchemaWithFallbacks = ( const main = mains?.[name]; const nameLower = name.toLowerCase(); const itemType = - main?.type ?? (isWebHookType(nameLower) ? nameLower : typeDefault); + main?.type ?? (isWebHookType(nameLower) ? nameLower : defaultType); // headers. const headers = isEmptyArray(main?.headers) ? schemaDataTypeDefaults.headers @@ -155,7 +149,7 @@ export const buildWebHooksSchemaWithFallbacks = ( fallback: { desired_status_code: '', max_tries: '', - type: itemType, + type: itemType as WebHookType, }, path: `${path} (defaults)`, schema: webhookSchema, @@ -181,7 +175,7 @@ export const buildWebHooksSchemaWithFallbacks = ( desired_status_code: '', max_tries: '', name: name, - type: main.type ?? typeDefault, + type: (main.type ?? defaultType) as WebHookType, }, path: `${path} (mains-${name}`, schema: webhookSchema, diff --git a/web/ui/react-app/src/utils/api/types/config/service/latest-version.ts b/web/ui/react-app/src/utils/api/types/config/service/latest-version.ts index f14000ac..07cdcc5b 100644 --- a/web/ui/react-app/src/utils/api/types/config/service/latest-version.ts +++ b/web/ui/react-app/src/utils/api/types/config/service/latest-version.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/utils/api/types/config/shared'; +import type { Command, Headers } from '@/utils/api/types/config/shared'; import type { NullString } from '@/utils/api/types/config-edit/shared/null-string'; export const LATEST_VERSION_LOOKUP_TYPE = { @@ -29,6 +29,7 @@ export type LatestVersionLookupDefaults = { access_token?: string; use_prerelease?: boolean; allow_invalid_certs?: boolean | null; + headers?: Headers; }; /* URL Command */ @@ -152,4 +153,5 @@ export type LatestVersionLookupGitHub = LatestVersionLookupBase & { export type LatestVersionLookupURL = LatestVersionLookupBase & { type: typeof LATEST_VERSION_LOOKUP_TYPE.URL.value | null; allow_invalid_certs?: boolean; + headers?: Headers; }; diff --git a/web/ui/react-app/src/utils/api/types/config/webhook.ts b/web/ui/react-app/src/utils/api/types/config/webhook.ts index 3560d598..c65fc73f 100644 --- a/web/ui/react-app/src/utils/api/types/config/webhook.ts +++ b/web/ui/react-app/src/utils/api/types/config/webhook.ts @@ -7,16 +7,13 @@ export const WEBHOOK_TYPE = { export type WebHookType = (typeof WEBHOOK_TYPE)[keyof typeof WEBHOOK_TYPE]['value']; export const webhookTypeOptions = Object.values(WEBHOOK_TYPE); -const webhookTypeValues: WebHookType[] = webhookTypeOptions.map( - (option) => option.value, -); -export const isWebHookType = (key: string): key is WebHookType => - (webhookTypeValues as string[]).includes(key.toLowerCase()); +export const isWebHookType = (value?: string | null): value is WebHookType => + value != null && webhookTypeOptions.some((v) => v.value === value); export type WebHook = { name: string; - type?: WebHookType; + type?: WebHookType | null; url?: string; allow_invalid_certs?: boolean | null; headers?: Headers; diff --git a/web/ui/react-app/tests/create-service.spec.ts b/web/ui/react-app/tests/create-service.spec.ts new file mode 100644 index 00000000..e2e73f32 --- /dev/null +++ b/web/ui/react-app/tests/create-service.spec.ts @@ -0,0 +1,289 @@ +import { expect, type Page, test } from '@playwright/test'; +import { + type CreateServiceOptions, + cleanupServices, + createService, + deleteService, + LOOKUP_LATEST_VERSION_JSON, + screenshot, + withProject, +} from './fixtures/service'; +import { + bareEndpoint, + LOOKUP_BASIC_AUTH, + LOOKUP_WITH_HEADER_AUTH, +} from './fixtures/test-endpoints'; + +// Creating a service makes a real server-side network call that can exceed the +// default timeout under load - so triple it. +test.beforeEach(() => { + test.slow(); +}); + +/** + * Runs a create -> verify -> refresh -> delete cycle, screenshotting each stage. + * + * @param page - The dashboard page. + * @param id - The (project-suffixed) ID used for backend operations. + * @param baseID - The raw name used for screenshot paths. + * @param projectName - The browser project name. + * @param options - Options passed through to `createService`. + */ +const runCreateServiceTest = async ( + page: Page, + id: string, + baseID: string, + projectName: string, + options?: CreateServiceOptions, +) => { + await page.goto('/'); + await page.getByRole('button', { name: /toggle edit mode/i }).click(); + await screenshot( + page, + `service-create/${baseID}/01-before-create`, + projectName, + ); + + await createService(page, id, options); + // `exact: true` - some IDs here are prefixes of others, and name matching is + // substring-based by default (would match multiple headings). + await expect( + page.getByRole('heading', { exact: true, name: id }), + ).toBeVisible(); + await screenshot( + page, + `service-create/${baseID}/02-after-create`, + projectName, + ); + + // `reload` (unlike `goto`) keeps edit mode active - no need to re-toggle. + await page.reload(); + await expect( + page.getByRole('heading', { exact: true, name: id }), + ).toBeVisible(); + await screenshot( + page, + `service-create/${baseID}/03-after-refresh`, + projectName, + ); + + await deleteService(page, id); + await screenshot( + page, + `service-create/${baseID}/04-after-delete`, + projectName, + ); +}; + +test.describe('Service creation', () => { + // Safety net for a test that fails before its own `deleteService` step. + let createdID: string | undefined; + test.afterEach(async ({ page }) => { + if (createdID) await cleanupServices(page, [createdID]); + createdID = undefined; + }); + + test('latest-version=github', async ({ page }, testInfo) => { + const baseID = 'LATEST_VERSION=GITHUB'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + await runCreateServiceTest(page, id, baseID, testInfo.project.name, { + latestVersion: { + type: 'github', + url: 'release-argus/Argus', + }, + }); + }); + + test('latest-version=url', async ({ page }, testInfo) => { + const baseID = 'LATEST_VERSION=URL'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + await runCreateServiceTest(page, id, baseID, testInfo.project.name, { + latestVersion: { + type: 'url', + url: bareEndpoint('1.2.3'), + }, + }); + }); + + test('deployed-version=manual', async ({ page }, testInfo) => { + const baseID = 'DEPLOYED_VERSION=MANUAL'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + await runCreateServiceTest(page, id, baseID, testInfo.project.name, { + deployedVersion: { + type: 'manual', + version: '1.2.3', + }, + }); + }); + + test('deployed-version=url (JSON)', async ({ page }, testInfo) => { + const baseID = 'DEPLOYED_VERSION=URL'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + await runCreateServiceTest(page, id, baseID, testInfo.project.name, { + deployedVersion: { + json: 'version', + type: 'url', + url: bareEndpoint('{"version":"1.2.3"}'), + }, + }); + }); + + test('deployed-version=url (basic auth)', async ({ page }, testInfo) => { + const baseID = 'DEPLOYED_VERSION=URL BASIC-AUTH'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + await runCreateServiceTest(page, id, baseID, testInfo.project.name, { + deployedVersion: { + basicAuth: { + password: LOOKUP_BASIC_AUTH.password, + username: LOOKUP_BASIC_AUTH.username, + }, + type: 'url', + url: LOOKUP_BASIC_AUTH.urlValid, + }, + // /basic-auth returns a sentence, not a bare semver - disable semantic versioning. + semanticVersioning: false, + }); + }); + + test('deployed-version=url (header auth)', async ({ page }, testInfo) => { + const baseID = 'DEPLOYED_VERSION=URL HEADER-AUTH'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + await runCreateServiceTest(page, id, baseID, testInfo.project.name, { + deployedVersion: { + headers: [ + { + key: LOOKUP_WITH_HEADER_AUTH.headerKey, + value: LOOKUP_WITH_HEADER_AUTH.headerValuePass, + }, + ], + type: 'url', + url: LOOKUP_WITH_HEADER_AUTH.urlValid, + }, + }); + }); +}); + +test.describe('Service update status', () => { + // Safety net for a test that fails before its own `deleteService` step. + let createdID: string | undefined; + test.afterEach(async ({ page }) => { + if (createdID) await cleanupServices(page, [createdID]); + createdID = undefined; + }); + + test('latest_version === deployed_version (up to date)', async ({ + page, + }, testInfo) => { + const baseID = 'UPDATE_STATUS=UP_TO_DATE'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + + await page.goto('/'); + await page.getByRole('button', { name: /toggle edit mode/i }).click(); + + // GIVEN: a service whose `latest_version` and `deployed_version` lookups + // both resolve to the same version (1.2.3). + await createService(page, id, { + deployedVersion: { type: 'manual', version: '1.2.3' }, + latestVersion: LOOKUP_LATEST_VERSION_JSON, + }); + await expect(page.getByRole('heading', { name: id })).toBeVisible(); + + const serviceCard = page.locator(`[data-service-id="${id}"]`); + + // THEN: the "Deployed" version is shown as 1.2.3. + await expect(serviceCard.getByText('1.2.3')).toBeVisible(); + + // AND: there is no separate "latest version" indicator for a different + // version - only the single (deployed) version value is rendered. + await expect(serviceCard.getByText(/^\d+\.\d+\.\d+$/)).toHaveCount(1); + + // AND: the card is not flagged as having an update available. + await expect(serviceCard).toHaveAttribute('data-update-available', 'false'); + + // AND: there is no "Skip" button (no update to skip). + await expect( + serviceCard.getByRole('button', { name: /reject release/i }), + ).not.toBeVisible(); + + await screenshot( + page, + `service-update-status/${baseID}/01-up-to-date`, + testInfo.project.name, + ); + }); + + test('latest_version !== deployed_version (update available)', async ({ + page, + }, testInfo) => { + const baseID = 'UPDATE_STATUS=AVAILABLE'; + const id = withProject(baseID, testInfo.project.name); + createdID = id; + + await page.goto('/'); + await page.getByRole('button', { name: /toggle edit mode/i }).click(); + + // GIVEN: a service whose `latest_version` (1.2.3) and `deployed_version` + // (0.0.1) lookups resolve to different versions. + await createService(page, id, { + deployedVersion: { type: 'manual', version: '0.0.1' }, + latestVersion: LOOKUP_LATEST_VERSION_JSON, + }); + await expect(page.getByRole('heading', { name: id })).toBeVisible(); + + const serviceCard = page.locator(`[data-service-id="${id}"]`); + const skipButton = serviceCard.getByRole('button', { + name: /reject release/i, + }); + + // THEN: both the "Latest" (1.2.3) and "Deployed" (0.0.1) versions are + // displayed. + await expect(serviceCard.getByText('1.2.3')).toBeVisible(); + await expect(serviceCard.getByText('0.0.1')).toBeVisible(); + + // AND: the card is flagged as having an update available. + await expect(serviceCard).toHaveAttribute('data-update-available', 'true'); + + // AND: a "Skip" button is visible. + await expect(skipButton).toBeVisible(); + await screenshot( + page, + `service-update-status/${baseID}/01-update-available`, + testInfo.project.name, + ); + + // WHEN: the user clicks "Skip" and confirms in the resulting modal. + await skipButton.click(); + const dialog = page.getByRole('dialog', { name: /skip this release\?/i }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText('Stay on: 0.0.1')).toBeVisible(); + await expect(dialog.getByText('Skip: 1.2.3')).toBeVisible(); + await screenshot( + page, + `service-update-status/${baseID}/02-skip-modal`, + testInfo.project.name, + ); + + await dialog.locator('#modal-action').click(); + + // THEN: the modal closes. + await expect(dialog).not.toBeVisible(); + + // AND: the card is no longer flagged as having an update available. + await expect(serviceCard).toHaveAttribute('data-update-available', 'false'); + + // AND: the "Skip" button is no longer shown. + await expect(skipButton).not.toBeVisible(); + await screenshot( + page, + `service-update-status/${baseID}/03-after-skip`, + testInfo.project.name, + ); + }); +}); diff --git a/web/ui/react-app/tests/dashboard.spec.ts b/web/ui/react-app/tests/dashboard.spec.ts new file mode 100644 index 00000000..6ce9c194 --- /dev/null +++ b/web/ui/react-app/tests/dashboard.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Dashboard', () => { + test('has the correct title', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/Argus/); + }); + + test('dashboard is visible', async ({ page }) => { + await page.goto('/'); + await expect( + page.getByRole('heading', { exact: true, name: 'release-argus/Argus' }), + ).toBeVisible(); + }); +}); diff --git a/web/ui/react-app/tests/fixtures/service.ts b/web/ui/react-app/tests/fixtures/service.ts new file mode 100644 index 00000000..36ba8aa8 --- /dev/null +++ b/web/ui/react-app/tests/fixtures/service.ts @@ -0,0 +1,527 @@ +import { expect, type Locator, type Page, test } from '@playwright/test'; +import { bareEndpoint } from './test-endpoints'; + +export type KeyVal = { key: string; value: string }; + +/** + * Appends the browser project's name to a base ID so browser tests never + * collide on the same literal name against the shared backend. + * + * @param id - The base service ID. + * @param projectName - The browser project name (`testInfo.project.name`). + * @returns The project-suffixed ID. + */ +export const withProject = (id: string, projectName: string) => + `${id}-${projectName}`; + +export type LatestVersionOptions = { + /* `latest_version.type` - defaults to 'github'. */ + type?: 'github' | 'url'; + /* `latest_version.url` (the GitHub "Repository" or generic "URL" field). */ + url: string; + /* `latest_version.allow_invalid_certs` - only applicable to type 'url'. */ + allowInvalidCerts?: boolean; + /* `latest_version.headers` - only applicable to type 'url'. */ + headers?: KeyVal[]; + /* `latest_version.url_commands` (only the first command, type 'regex') - + * only applicable to type 'url'. Used to extract a version from a + * response body that isn't a bare semver string. */ + urlCommandRegex?: { regex: string; index?: string }; +}; + +export type DeployedVersionOptions = + | { + /* `deployed_version.type` = 'manual'. */ + type: 'manual'; + /* `deployed_version.version`. */ + version: string; + } + | { + /* `deployed_version.type` = 'url'. */ + type: 'url'; + /* `deployed_version.url`. */ + url: string; + /* `deployed_version.method` - defaults to 'GET'. */ + method?: 'GET' | 'POST'; + /* `deployed_version.allow_invalid_certs`. */ + allowInvalidCerts?: boolean; + /* `deployed_version.basic_auth`. */ + basicAuth?: { username: string; password: string }; + /* `deployed_version.headers`. */ + headers?: KeyVal[]; + /* `deployed_version.body` - only sent when `method` is 'POST'. */ + body?: string; + /* `deployed_version.target_header`. */ + targetHeader?: string; + /* `deployed_version.json`. */ + json?: string; + /* `deployed_version.regex`. */ + regex?: string; + }; + +export type WebHookOptions = { + /* `webhook.X.name`. */ + name: string; + /* `webhook.X.url`. */ + url: string; + /* `webhook.X.secret`. */ + secret: string; + /* `webhook.X.max_tries`. */ + maxTries?: string; +}; + +/* A `gotify` notifier. */ +export type NotifyOptions = { + /* `notify.X.type` - only 'gotify' is supported here. */ + type: 'gotify'; + /* `notify.X.name`. */ + name: string; + /* `notify.X.url_fields.host`. */ + host: string; + /* `notify.X.url_fields.path`. */ + path?: string; + /* `notify.X.url_fields.token` (the masked secret). */ + token: string; + /* `notify.X.params.title`. */ + title?: string; +}; + +export type CreateServiceOptions = { + latestVersion?: LatestVersionOptions; + deployedVersion?: DeployedVersionOptions; + /* `options.semantic_versioning` - set to `false` for lookups whose raw + * response isn't a parseable MAJOR.MINOR.PATCH version. */ + semanticVersioning?: boolean; + notifiers?: NotifyOptions[]; + webhooks?: WebHookOptions[]; +}; + +// A `url` latest-version lookup against the JSON fixture, extracting its +// version via a regex url_command. +export const LOOKUP_LATEST_VERSION_JSON: LatestVersionOptions = { + allowInvalidCerts: false, + type: 'url', + url: bareEndpoint('{"version":"1.2.3"}'), + urlCommandRegex: { + index: '0', + regex: '"version":\\s*"([\\d.]+)"', + }, +}; + +/** + * Screenshots `page` to `test-results/screenshots///.png`. + * + * @param page - The page to capture. + * @param name - Path-like `/` (no browser segment or extension). + * @param projectName - Browser project; becomes the `` segment (its + * `-mutating` suffix stripped) so projects don't collide. + * @param centerOn - If set, scroll this field to centre and capture only the + * viewport rather than the full page. + * @returns The screenshot buffer. + */ +export const screenshot = async ( + page: Page, + name: string, + projectName: string, + centerOn?: Locator, +) => { + const lastSlash = name.lastIndexOf('/'); + const dir = name.slice(0, lastSlash + 1); + const file = name.slice(lastSlash + 1); + const browser = projectName.replace(/-mutating$/, ''); + if (centerOn) { + await centerOn.evaluate((el) => + el.scrollIntoView({ behavior: 'instant', block: 'center' }), + ); + } + return page.screenshot({ + // Fast-forward in-flight CSS transitions so a field whose validation + // just cleared isn't captured mid-fade. + animations: 'disabled', + fullPage: !centerOn, + path: `test-results/screenshots/${dir}${browser}/${file}.png`, + }); +}; + +// A filesystem-safe slug of a test's title - used as a per-test folder name. +const titleSlug = (title: string) => + title.replace(/[^\w=.]+/g, '-').replace(/^-+|-+$/g, ''); + +/** + * Binds `screenshot` to a fixed `base` dir and project. Each call's screenshot + * lands under `base//`, so every test in a describe gets its own + * folder (the test title comes from `test.info()`). + * + * @param page - The page to capture. + * @param projectName - The browser project name. + * @param base - The shared screenshot directory prefix. + * @returns A bound screenshot taker. + */ +export const screenshotsUnder = + (page: Page, projectName: string, base: string) => + (file: string, centerOn?: Locator) => + screenshot( + page, + `${base}/${titleSlug(test.info().title)}/${file}`, + projectName, + centerOn, + ); + +/** + * Fills the headers map for a lookup `section`, adding a row per entry. + * + * @param section - The accordion section containing the headers map. + * @param prefix - The lookup's field-name prefix (`latest_version` or + * `deployed_version`); the rows live under `.headers`. + * @param entries - The key/value pairs to add. + */ +const fillHeaders = async ( + section: Locator, + prefix: 'latest_version' | 'deployed_version', + entries: KeyVal[], +) => { + for (let index = 0; index < entries.length; index++) { + await section.getByRole('button', { name: /add new headers/i }).click(); + await section + .locator(`input[name="${prefix}.headers.${index}.key"]`) + .fill(entries[index].key); + await section + .locator(`input[name="${prefix}.headers.${index}.value"]`) + .fill(entries[index].value); + } +}; + +/** + * Sets a Yes/No/Default toggle for `fieldName`. + * + * @param section - The accordion section containing the toggle. + * @param fieldName - The form field name. + * @param value - `true` selects Yes, `false` No; omit to select Default. + */ +export const setBooleanWithDefault = async ( + section: Locator, + fieldName: string, + value?: boolean, +) => { + const name = + value === undefined ? /^Default:?$/i : value ? /^Yes$/i : /^No$/i; + await section + .locator(`[aria-labelledby="${fieldName}-label"]`) + .getByRole('radio', { name }) + .click(); +}; + +/** + * Fills in the `latest_version` accordion section. + * + * @param dialog - The modal dialog. + * @param options - The latest-version lookup options. + */ +const fillLatestVersion = async ( + dialog: Locator, + options: LatestVersionOptions, +) => { + const type = options.type ?? 'github'; + + await dialog.getByRole('button', { name: /^Latest Version:?$/i }).click(); + const section = dialog + .locator('[data-slot="accordion-item"]', { hasText: 'Latest Version' }) + .first(); + + await section.locator('#latest_version\\.type').click(); + await dialog.getByRole('option', { name: type }).click(); + + if (type === 'github') { + await section + .getByRole('textbox', { name: /repository/i }) + .fill(options.url); + return; + } + + // type === 'url' + await section + .getByRole('textbox', { name: /^Value field for URL$/i }) + .fill(options.url); + + if (options.allowInvalidCerts !== undefined) { + await setBooleanWithDefault( + section, + 'latest_version.allow_invalid_certs', + options.allowInvalidCerts, + ); + } + + if (options.headers?.length) { + await fillHeaders(section, 'latest_version', options.headers); + } + + if (options.urlCommandRegex) { + // Add a regex url_command to extract the version from the response body. + await section.getByRole('button', { name: /add new url command/i }).click(); + await section + .locator('input[name="latest_version.url_commands.0.regex"]') + .fill(options.urlCommandRegex.regex); + if (options.urlCommandRegex.index !== undefined) { + await section + .locator('input[name="latest_version.url_commands.0.index"]') + .fill(options.urlCommandRegex.index); + } + } +}; + +/** + * Fills in the `deployed_version` accordion section. + * + * @param dialog - The modal dialog. + * @param options - The deployed-version lookup options. + */ +const fillDeployedVersion = async ( + dialog: Locator, + options: DeployedVersionOptions, +) => { + await dialog.getByRole('button', { name: /^Deployed Version:?$/i }).click(); + const section = dialog + .locator('[data-slot="accordion-item"]', { hasText: 'Deployed Version' }) + .first(); + + await section.locator('#deployed_version\\.type').click(); + await dialog.getByRole('option', { name: options.type }).click(); + + if (options.type === 'manual') { + await section + .locator('input[name="deployed_version.version"]') + .fill(options.version); + return; + } + + // type === 'url' + await section + .getByRole('textbox', { name: /^Value field for URL$/i }) + .fill(options.url); + + if (options.method) { + await section.locator('#deployed_version\\.method').click(); + await dialog + .getByRole('option', { exact: true, name: options.method }) + .click(); + } + + if (options.allowInvalidCerts !== undefined) { + await setBooleanWithDefault( + section, + 'deployed_version.allow_invalid_certs', + options.allowInvalidCerts, + ); + } + + if (options.basicAuth) { + await section + .locator('input[name="deployed_version.basic_auth.username"]') + .fill(options.basicAuth.username); + await section + .locator('input[name="deployed_version.basic_auth.password"]') + .fill(options.basicAuth.password); + } + + if (options.headers?.length) { + await fillHeaders(section, 'deployed_version', options.headers); + } + + if (options.method === 'POST' && options.body !== undefined) { + await section.getByRole('textbox', { name: /^Body$/i }).fill(options.body); + } + + if (options.targetHeader !== undefined) { + await section + .getByRole('textbox', { name: /target header/i }) + .fill(options.targetHeader); + } + + if (options.json !== undefined) { + await section.getByRole('textbox', { name: /json/i }).fill(options.json); + } + + if (options.regex !== undefined) { + await section + .getByRole('textbox', { name: 'Value field for RegEx' }) + .fill(options.regex); + } +}; + +/** + * Fills in the `webhook` accordion section, adding one item per entry. + * + * @param dialog - The modal dialog. + * @param webhooks - The webhooks to add. + */ +const fillWebHooks = async (dialog: Locator, webhooks: WebHookOptions[]) => { + await dialog.getByRole('button', { name: /^WebHook:?$/i }).click(); + const section = dialog + .locator('[data-slot="accordion-item"]', { hasText: 'WebHook' }) + .first(); + + for (let index = 0; index < webhooks.length; index++) { + await section.getByRole('button', { name: /add webhook/i }).click(); + + const item = section.locator('[data-slot="accordion-item"]').nth(index); + await item.locator('[data-slot="accordion-trigger"]').click(); + + await item + .getByRole('textbox', { name: /^Value field for Name$/i }) + .fill(webhooks[index].name); + await item + .getByRole('textbox', { name: /^Value field for Target URL$/i }) + .fill(webhooks[index].url); + await item + .getByRole('textbox', { name: /^Value field for Secret$/i }) + .fill(webhooks[index].secret); + + const { maxTries } = webhooks[index]; + if (maxTries !== undefined) { + await item + .getByRole('textbox', { name: /^Value field for Max tries$/i }) + .fill(maxTries); + } + } +}; + +/** + * Fills in the `notify` accordion section, adding one item per entry. + * + * @param dialog - The modal dialog. + * @param notifiers - The notifiers to add. + */ +const fillNotify = async (dialog: Locator, notifiers: NotifyOptions[]) => { + await dialog.getByRole('button', { name: /^Notify:?$/i }).click(); + const section = dialog + .locator('[data-slot="accordion-item"]', { hasText: 'Notify' }) + .first(); + + for (let index = 0; index < notifiers.length; index++) { + const notify = notifiers[index]; + await section.getByRole('button', { name: /add notify/i }).click(); + + const item = section.locator('[data-slot="accordion-item"]').nth(index); + await item.locator('[data-slot="accordion-trigger"]').click(); + + await section.locator(`#notify\\.${index}\\.type`).click(); + await dialog.getByRole('option', { exact: true, name: 'Gotify' }).click(); + + await item + .getByRole('textbox', { name: /^Value field for Name$/i }) + .fill(notify.name); + await item + .getByRole('textbox', { name: /^Value field for Host$/i }) + .fill(notify.host); + if (notify.path !== undefined) { + await item + .getByRole('textbox', { name: /^Value field for Path$/i }) + .fill(notify.path); + } + await item + .getByRole('textbox', { name: /^Value field for Token$/i }) + .fill(notify.token); + if (notify.title !== undefined) { + await item + .getByRole('textbox', { name: /^Value field for Title$/i }) + .fill(notify.title); + } + } +}; + +/** + * Creates a service. Defaults to a `url` 'latest version' lookup against the + * test server's `/bare/1.2.3` endpoint; pass `latestVersion`/`deployedVersion` + * to exercise other lookup types. + * + * @param page - The dashboard page (edit mode must already be on). + * @param id - The service ID. + * @param options - Lookup/notify/webhook options for the service. + */ +export const createService = async ( + page: Page, + id: string, + options?: CreateServiceOptions, +) => { + await page.getByRole('button', { name: /create a service/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.locator('input[name="id"]').fill(id); + + await fillLatestVersion( + dialog, + options?.latestVersion ?? LOOKUP_LATEST_VERSION_JSON, + ); + + if (options?.deployedVersion) { + await fillDeployedVersion(dialog, options.deployedVersion); + } + + if (options?.semanticVersioning !== undefined) { + await dialog.getByRole('button', { name: /^Options:?$/i }).click(); + const optionsSection = dialog + .locator('[data-slot="accordion-item"]', { hasText: 'Options' }) + .first(); + await setBooleanWithDefault( + optionsSection, + 'options.semantic_versioning', + options.semanticVersioning, + ); + } + + if (options?.notifiers?.length) { + await fillNotify(dialog, options.notifiers); + } + + if (options?.webhooks?.length) { + await fillWebHooks(dialog, options.webhooks); + } + + await dialog.locator('#modal-action').click(); + // The server verifies the lookup via a real network call before + // responding, so the modal can take longer than the default 5s to close. + await expect(dialog).not.toBeVisible({ timeout: 30_000 }); +}; + +/** + * Deletes the service with the given ID via its edit modal. + * + * @param page - The dashboard page (edit mode must already be on). + * @param serviceID - The ID of the service to delete. + */ +export const deleteService = async (page: Page, serviceID: string) => { + const serviceCard = page.locator(`[data-service-id="${serviceID}"]`); + await serviceCard.getByRole('button', { name: /edit/i }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: /^Delete$/i }).click(); + + // Confirm in the resulting dialog. + await page + .getByRole('button', { name: /^Delete$/i }) + .last() + .click(); + + // The card disappears once the delete is broadcast over the WebSocket - + // allow extra time under parallel load. + await expect( + page.locator(`[data-service-id="${serviceID}"]`), + ).not.toBeVisible({ timeout: 30_000 }); +}; + +/** + * Deletes each of `serviceIDs` that is currently present. + * + * @param page - The dashboard page. + * @param serviceIDs - The IDs to remove if present. + */ +export const cleanupServices = async (page: Page, serviceIDs: string[]) => { + await page.goto('/'); + await page.getByRole('button', { name: /toggle edit mode/i }).click(); + for (const id of serviceIDs) { + if (await page.locator(`[data-service-id="${id}"]`).isVisible()) { + await deleteService(page, id); + } + } +}; diff --git a/web/ui/react-app/tests/fixtures/test-endpoints.ts b/web/ui/react-app/tests/fixtures/test-endpoints.ts new file mode 100644 index 00000000..a69a9b50 --- /dev/null +++ b/web/ui/react-app/tests/fixtures/test-endpoints.ts @@ -0,0 +1,60 @@ +// Test endpoints mirrored from `test/secrets.go` - keep in sync with it. + +// Domain with a valid TLS certificate. +export const VALID_CERT_NO_PROTOCOL = 'valid.release-argus.io'; +export const VALID_CERT_HTTPS = `https://${VALID_CERT_NO_PROTOCOL}`; + +// Domain with an invalid/self-signed TLS certificate. +export const INVALID_CERT_HTTPS = 'https://invalid.release-argus.io'; + +/** + * Builds a `/bare/` URL on the test server, which echoes + * `body` back as the response. Lets a test pin the exact lookup response. + * + * @param body - The exact response body the endpoint should return. + * @param invalidCert - Target the invalid-cert host instead of the valid one. + */ +export const bareEndpoint = (body: string, invalidCert = false) => + `${invalidCert ? INVALID_CERT_HTTPS : VALID_CERT_HTTPS}/bare/${encodeURIComponent(body)}`; + +// Version carried in a response header. +export const LOOKUP_RESPONSE_HEADER = { + headerKeyFail: 'X-Version-Foo', + headerKeyPass: 'X-Version-Here', + headerKeyPassMixedCase: 'x-VeRSioN-HERe', + urlInvalid: `${INVALID_CERT_HTTPS}/header`, + urlValid: `${VALID_CERT_HTTPS}/header`, +}; + +// Requires a custom request header to succeed. +export const LOOKUP_WITH_HEADER_AUTH = { + headerKey: 'X-Test', + headerValueFail: 'secret-', + headerValuePass: 'secret', + urlInvalid: `${INVALID_CERT_HTTPS}/hooks/single-header`, + urlValid: `${VALID_CERT_HTTPS}/hooks/single-header`, +}; + +// Requires HTTP basic auth to succeed. +export const LOOKUP_BASIC_AUTH = { + password: '123', + urlInvalid: `${INVALID_CERT_HTTPS}/basic-auth`, + urlValid: `${VALID_CERT_HTTPS}/basic-auth`, + username: 'test', +}; + +// WebHook receiver requiring a matching secret. +export const WEBHOOK_GITHUB = { + secretFail: 'argus-', + secretPass: 'argus', + urlInvalid: `${INVALID_CERT_HTTPS}/hooks/github-style`, + urlValid: `${VALID_CERT_HTTPS}/hooks/github-style`, +}; + +// A real Gotify endpoint that accepts `tokenPass`, rejecting anything else. +export const NOTIFY_GOTIFY = { + host: VALID_CERT_NO_PROTOCOL, + path: '/gotify', + // trunk-ignore(gitleaks/generic-api-key) + tokenPass: 'AGE-LlHU89Q56uQ', +}; diff --git a/web/ui/react-app/tests/fixtures/validation.ts b/web/ui/react-app/tests/fixtures/validation.ts new file mode 100644 index 00000000..de76b915 --- /dev/null +++ b/web/ui/react-app/tests/fixtures/validation.ts @@ -0,0 +1,341 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +// Validation messages for the modal forms. +export const REQUIRED = 'Required.'; +export const NUMBER_REQUIRED = 'Number required.'; +export const MUST_BE_UNIQUE = 'Must be unique.'; + +/** + * The field container for `input` - scopes assertions to one field's error so + * they don't match other fields' errors. + * + * @param input - The field's input element. + * @returns The enclosing `[data-slot="field"]` container. + */ +export const fieldOf = (input: Locator) => + input.locator('xpath=ancestor::*[@data-slot="field"][1]'); + +/** + * Opens the "Create a service" modal, entering edit mode first. + * + * @param page - The dashboard page. + * @returns The open modal dialog. + */ +export const openCreateServiceModal = async (page: Page) => { + await page.goto('/'); + // Wait for /api/v1/service/order response. + await expect(page.locator('[data-service-id]').first()).toBeVisible(); + + await page.getByRole('button', { name: /toggle edit mode/i }).click(); + await page.getByRole('button', { name: /create a service/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + return dialog; +}; + +/** + * Expands the top-level accordion section with the given name. + * + * @param dialog - The modal dialog. + * @param name - The section's heading text (e.g. 'Latest Version'). + * @returns The section's accordion-item container. + */ +export const openSection = async (dialog: Locator, name: string) => { + await dialog + .getByRole('button', { name: new RegExp(`^${name}:?$`, 'i') }) + .click(); + return dialog + .locator('[data-slot="accordion-item"]', { hasText: name }) + .first(); +}; + +/** + * Waits for an accordion item's resize animations to finish before a + * screenshot, so error text isn't captured mid-resize. + * + * @param section - The accordion item. + */ +export const waitForAccordionAnimations = (section: Locator) => + section.evaluate((el) => + Promise.all( + el + .getAnimations({ subtree: true }) + .map((a) => a.finished.catch(() => undefined)), + ), + ); + +/** + * Activates a button via keyboard (focus + Enter). Used for icon-only + * add/remove buttons whose empty-array row can intercept the pointer and make + * `.click()` time out - keyboard activation skips hit-testing. + * + * @param button - The button to activate. + */ +export const clickViaKeyboard = async (button: Locator) => { + await button.focus(); + await button.press('Enter'); +}; + +// A bound screenshot taker (from `screenshotsUnder`): a leaf file name and an +// optional field to centre on. +type Shot = (file: string, centerOn?: Locator) => Promise; + +/** + * Adds a notifier to the (already-open) Notify `section` and expands it. + * + * @param section - The Notify accordion section. + * @param dialog - The modal dialog (for the type-select options). + * @param type - Visible option label (e.g. 'Gotify') to switch from the default + * notifier; omit to leave it as-is. + */ +export const addNotify = async ( + section: Locator, + dialog: Locator, + type?: string, +) => { + await section.getByRole('button', { name: /add notify/i }).click(); + const header = section.locator('[data-slot="accordion-trigger"]', { + hasText: /^0:/, + }); + await expect(header).toBeVisible(); + await header.click(); + if (!type) return; + await section.locator('#notify\\.0\\.type').click(); + await dialog.getByRole('option', { exact: true, name: type }).click(); +}; + +/** + * The "Value field for