Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@AGENTS.md

## Claude Code 固有

- テストを追加・変更したら `dotnet-skills:slopwatch` を実行する(dotnet-skills プラグイン導入時)
61 changes: 42 additions & 19 deletions scripts/local-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,27 @@ wait_until_available() {
local startup_log_path="$2"
local timeout_seconds="${3:-30}"
local deadline=$((SECONDS + timeout_seconds))
local http_code=""

while (( SECONDS < deadline )); do
if [[ -n "$startup_log_path" ]] && [[ -f "$startup_log_path" ]] \
&& grep -Fq "Now listening on: $uri" "$startup_log_path"; then
return 0
fi

if curl -sS --output /dev/null --max-time 5 "$uri"; then
# curl exits 0 for any HTTP response it receives, 5xx included, so the exit
# code alone cannot tell "ready" from "up but still failing". App Service
# answers 500 while it finishes starting, which would otherwise let the
# smoke run proceed against an app that is not serving yet.
http_code="$(curl -sS --output /dev/null --max-time 5 -w '%{http_code}' "$uri" 2>/dev/null)" || http_code=""
if [[ "$http_code" == "200" ]]; then
return 0
fi

sleep 0.5
done

echo "Timed out waiting for $uri" >&2
echo "Timed out waiting for $uri (last status: ${http_code:-no response})" >&2
return 1
}

Expand All @@ -71,14 +77,24 @@ resolve_request_uri() {
printf '%s/%s' "${base_url%/}" "${candidate#/}"
}

# Usage: perform_request <label> <expected_status> [curl args...]
#
# <label> names the endpoint in failure output. Never pass "$@" or a raw curl
# argument list here: the login POST carries the smoke account password, and
# anything printed from this function lands in the CD log.
perform_request() {
local label="$1"
local expected_status="$2"
shift 2

local max_retries=4
local retry_delay="${SMOKE_RETRY_DELAY_SECONDS:-10}"
local attempt=1

while true; do
: >"$RESPONSE_BODY_PATH"
: >"$RESPONSE_HEADERS_PATH"

set +e
local http_code
http_code="$(curl -sS \
Expand All @@ -89,23 +105,30 @@ perform_request() {
local curl_exit=$?
set -e

# If curl succeeded AND HTTP status is not a 5xx server error, return success
if [[ $curl_exit -eq 0 && ! "$http_code" =~ ^5 ]]; then
# Retry a 5xx only when it is unexpected. Some checks assert a server error
# on purpose (/Error/500), and retrying those wasted 30s per run while
# logging failures that were actually the expected result.
if [[ $curl_exit -eq 0 ]] \
&& { [[ ! "$http_code" =~ ^5 ]] || [[ "$http_code" == "$expected_status" ]]; }; then
printf '%s' "$http_code"
return 0
fi

if (( attempt >= max_retries )); then
if [[ $curl_exit -eq 0 ]]; then
# Hand the status back and let the caller report the mismatch.
printf '%s' "$http_code"
return 0
else
return "$curl_exit"
fi
# Returning non-zero aborts the caller's assignment under `set -e` before
# it can print anything, so name the endpoint here or the failure is
# anonymous, which is exactly what happened in the CD run for #135.
echo "$label: request failed after $max_retries attempts (curl exit: $curl_exit)." >&2
return "$curl_exit"
fi

echo "Request failed (curl exit: $curl_exit, HTTP status: $http_code). App Service might be restarting, retrying in 10s... ($attempt/$max_retries)" >&2
sleep 10
echo "$label: request failed (curl exit: $curl_exit, HTTP status: $http_code). Retrying in ${retry_delay}s... ($attempt/$max_retries)" >&2
sleep "$retry_delay"
attempt=$((attempt + 1))
done
}
Expand Down Expand Up @@ -224,26 +247,26 @@ fi

wait_until_available "$BASE_URL" "$([[ "$USE_EXISTING_APP" == "true" ]] && printf '' || printf '%s' "$STDOUT_PATH")" "$TIMEOUT_SECONDS"

status_code="$(perform_request "$BASE_URL/")"
status_code="$(perform_request "Home page" 200 "$BASE_URL/")"
if [[ "$status_code" != "200" ]]; then
echo "Home page returned unexpected status code: $status_code" >&2
exit 1
fi

status_code="$(perform_request "$BASE_URL/Identity/Account/Login")"
status_code="$(perform_request "Login page" 200 "$BASE_URL/Identity/Account/Login")"
if [[ "$status_code" != "200" ]]; then
echo "Login page returned unexpected status code: $status_code" >&2
exit 1
fi

status_code="$(perform_request --max-redirs 0 "$BASE_URL/MyPage")"
status_code="$(perform_request "Anonymous /MyPage" 302 --max-redirs 0 "$BASE_URL/MyPage")"
if [[ "$status_code" != "302" ]]; then
echo "Anonymous /MyPage request returned unexpected status code: $status_code" >&2
exit 1
fi

for expected_status in 400 403 404 500; do
status_code="$(perform_request "$BASE_URL/Error/$expected_status")"
status_code="$(perform_request "/Error/$expected_status" "$expected_status" "$BASE_URL/Error/$expected_status")"
if [[ "$status_code" != "$expected_status" ]]; then
echo "/Error/$expected_status returned unexpected status code: $status_code" >&2
exit 1
Expand All @@ -258,7 +281,7 @@ if [[ -z "$EMAIL" || -z "$PASSWORD" ]]; then
exit 0
fi

status_code="$(perform_request -c "$COOKIE_JAR_PATH" -b "$COOKIE_JAR_PATH" "$BASE_URL/Identity/Account/Login")"
status_code="$(perform_request "Login page (authenticated)" 200 -c "$COOKIE_JAR_PATH" -b "$COOKIE_JAR_PATH" "$BASE_URL/Identity/Account/Login")"
if [[ "$status_code" != "200" ]]; then
echo "Login page for authenticated smoke returned unexpected status code: $status_code" >&2
exit 1
Expand All @@ -270,7 +293,7 @@ ANTIFORGERY_TOKEN="$(extract_antiforgery_token)" || {
}

status_code="$(
perform_request \
perform_request "Login POST" 302 \
-X POST \
-c "$COOKIE_JAR_PATH" \
-b "$COOKIE_JAR_PATH" \
Expand All @@ -286,21 +309,21 @@ if [[ "$status_code" != "302" ]]; then
exit 1
fi

status_code="$(perform_request -c "$COOKIE_JAR_PATH" -b "$COOKIE_JAR_PATH" "$BASE_URL/MyPage")"
status_code="$(perform_request "Authenticated /MyPage" 200 -c "$COOKIE_JAR_PATH" -b "$COOKIE_JAR_PATH" "$BASE_URL/MyPage")"
if [[ "$status_code" != "200" ]]; then
echo "Authenticated /MyPage returned unexpected status code: $status_code" >&2
exit 1
fi

status_code="$(perform_request -c "$COOKIE_JAR_PATH" -b "$COOKIE_JAR_PATH" "$BASE_URL/Pets")"
status_code="$(perform_request "Authenticated /Pets" 200 -c "$COOKIE_JAR_PATH" -b "$COOKIE_JAR_PATH" "$BASE_URL/Pets")"
if [[ "$status_code" != "200" ]]; then
echo "Authenticated /Pets returned unexpected status code: $status_code" >&2
exit 1
fi

if [[ -n "$IMAGE_URL" ]]; then
RESOLVED_IMAGE_URL="$(resolve_request_uri "$BASE_URL" "$IMAGE_URL")"
status_code="$(perform_request -c "$COOKIE_JAR_PATH" -b "$COOKIE_JAR_PATH" "$RESOLVED_IMAGE_URL")"
status_code="$(perform_request "Authenticated image request" 200 -c "$COOKIE_JAR_PATH" -b "$COOKIE_JAR_PATH" "$RESOLVED_IMAGE_URL")"
if [[ "$status_code" != "200" ]]; then
echo "Authenticated image request returned unexpected status code: $status_code" >&2
exit 1
Expand All @@ -313,7 +336,7 @@ if [[ -n "$IMAGE_URL" ]]; then
fi

if [[ "$EXPECT_ADMIN" == "true" ]]; then
status_code="$(perform_request -c "$COOKIE_JAR_PATH" -b "$COOKIE_JAR_PATH" "$BASE_URL/Admin/Users")"
status_code="$(perform_request "Authenticated /Admin/Users" 200 -c "$COOKIE_JAR_PATH" -b "$COOKIE_JAR_PATH" "$BASE_URL/Admin/Users")"
if [[ "$status_code" != "200" ]]; then
echo "Authenticated /Admin/Users returned unexpected status code: $status_code" >&2
exit 1
Expand Down
39 changes: 39 additions & 0 deletions scripts/test.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,42 @@ if ($DotnetArgs.Count -gt 0 -and $DotnetArgs[0] -eq '--') {

$TestArgs = @('test', '-c', $Configuration) + $DotnetArgs
Invoke-Dotnet $TestArgs

# Shell-level regression tests for the CD smoke script. CI runs these through
# test.sh; this keeps the PowerShell entry point in step for local runs. bash
# ships with Git for Windows, but skip rather than fail if it is absent.
$ShellTests = Join-Path $RepoRoot 'tests/scripts/local-smoke.tests.sh'

# Git for Windows first. `bash` on PATH is usually C:\Windows\System32\bash.exe,
# the WSL launcher, which fails with "execvpe(/bin/bash)" when no distro is
# installed -- so a PATH lookup alone is not enough.
$BashPath = @(
(Join-Path $env:ProgramFiles 'Git\bin\bash.exe')
(Join-Path ${env:ProgramFiles(x86)} 'Git\bin\bash.exe')
) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1

if (-not $BashPath) {
$BashCommand = Get-Command bash -ErrorAction SilentlyContinue
if ($BashCommand -and $BashCommand.Source -notlike "$env:SystemRoot*") {
$BashPath = $BashCommand.Source
}
}

if (-not $BashPath) {
Write-Warn "Git Bash not found; skipping shell script tests ($ShellTests). CI runs them via test.sh."
}
else {
Write-Info 'Running shell script tests...'

# -l matters: started as a non-login shell, bin\bash.exe does not put Git's
# usr\bin on PATH in every installation, and the tests then die on
# dirname/mktemp/tr before reaching anything they assert. Prepending those
# directories from PowerShell instead was measured on an affected machine and
# does not work, because the additions do not survive into Git Bash's own
# PATH. The test script derives its paths from BASH_SOURCE, so a profile that
# changes directory is harmless.
& $BashPath -l $ShellTests
if ($LASTEXITCODE -ne 0) {
throw "Shell script tests failed with exit code $LASTEXITCODE."
}
}
6 changes: 6 additions & 0 deletions scripts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,9 @@ if [[ "${1:-}" == "--configuration" ]]; then
fi

dotnet test -c "$CONFIGURATION" "$@"

# Shell-level regression tests for the CD smoke script. They run here rather
# than as a separate workflow step so CI picks them up through the entry point
# it already calls.
log "Running shell script tests..."
bash "$SCRIPT_DIR/../tests/scripts/local-smoke.tests.sh"
76 changes: 76 additions & 0 deletions tests/scripts/fake-curl.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Stand-in for curl, used by local-smoke.tests.sh.
#
# Responses are scripted per URL through queue files in $FAKE_CURL_DIR:
# q_<sanitized-url> one "<curl_exit>|<http_status>" per line, consumed in order
# An empty or missing queue falls back to a successful 200, so a test only has
# to describe the endpoints it actually cares about.
#
# Invocations are recorded in $FAKE_CURL_DIR/calls.log as bare URLs. The
# argument list is deliberately never logged: the login POST carries the smoke
# account password.
set -uo pipefail

out=""
dump=""
url=""

args=("$@")
i=0
while (( i < ${#args[@]} )); do
case "${args[$i]}" in
-o|--output)
i=$((i + 1))
out="${args[$i]:-}"
;;
-D|--dump-header)
i=$((i + 1))
dump="${args[$i]:-}"
;;
# Flags that take a value we do not care about; skip the value so it is
# never mistaken for the URL.
-w|--write-out|-c|--cookie-jar|-b|--cookie|-X|--request|--max-time|--max-redirs|--data-urlencode|--data)
i=$((i + 1))
;;
http://*|https://*)
url="${args[$i]}"
;;
esac
i=$((i + 1))
done

printf '%s\n' "$url" >>"$FAKE_CURL_DIR/calls.log"

# A trailing slash is dropped so readiness ("$BASE_URL") and the home-page check
# ("$BASE_URL/") draw from one queue. They hit the same endpoint, so a test that
# gave them separate queues could not tell whether readiness actually waited.
key="$(printf '%s' "${url#*://}" | sed 's:/$::' | tr -c 'A-Za-z0-9' '_')"
queue="$FAKE_CURL_DIR/q_$key"

curl_exit=0
status=200
if [[ -s "$queue" ]]; then
first_line="$(head -n 1 "$queue")"
IFS='|' read -r curl_exit status <<<"$first_line"
tail -n +2 "$queue" >"$queue.tmp" && mv "$queue.tmp" "$queue"
fi

if [[ -n "$out" ]]; then
# The login page has to carry a token or extract_antiforgery_token aborts.
if [[ "$url" == *Login* ]]; then
printf '%s' '<input name="__RequestVerificationToken" type="hidden" value="fake-token" />' >"$out"
else
printf '%s' '<html></html>' >"$out"
fi
fi

if [[ -n "$dump" ]]; then
printf 'HTTP/1.1 %s\r\nX-Content-Type-Options: nosniff\r\n\r\n' "$status" >"$dump"
fi

if (( curl_exit != 0 )); then
echo "curl: ($curl_exit) fake transport failure" >&2
exit "$curl_exit"
fi

printf '%s' "$status"
Loading