HH-446: restore auditable quality gates from current main (#102)
* HH-446: restore auditable quality gates * test(HH-446): restore help center smoke contract --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Executable
+173
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate Go test coverage, execution evidence, and elapsed time."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def coverage(path):
|
||||
files = defaultdict(lambda: [0, 0])
|
||||
total = [0, 0]
|
||||
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
||||
if not line or line.startswith("mode:"):
|
||||
continue
|
||||
fields = line.split()
|
||||
if len(fields) != 3:
|
||||
raise ValueError(f"invalid coverage line: {line}")
|
||||
file_name = fields[0].split(":", 1)[0]
|
||||
statements, count = int(fields[1]), int(fields[2])
|
||||
total[0] += statements
|
||||
total[1] += statements if count else 0
|
||||
files[file_name][0] += statements
|
||||
files[file_name][1] += statements if count else 0
|
||||
if total[0] == 0:
|
||||
raise ValueError("coverage profile has no statements")
|
||||
return total, files
|
||||
|
||||
|
||||
def test_evidence(path):
|
||||
packages = defaultdict(lambda: {"pass": 0, "fail": 0, "skip": 0})
|
||||
totals = {"pass": 0, "fail": 0, "skip": 0}
|
||||
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
action = event.get("Action")
|
||||
package = event.get("Package", "")
|
||||
if not event.get("Test") or action not in totals:
|
||||
continue
|
||||
packages[package][action] += 1
|
||||
totals[action] += 1
|
||||
return totals, packages
|
||||
|
||||
|
||||
def package_matches(name, spec):
|
||||
recursive = spec.endswith("/...")
|
||||
package = spec.removesuffix("/...")
|
||||
return name == package or recursive and name.startswith(package + "/")
|
||||
|
||||
|
||||
def run(argv=None):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--coverage", required=True)
|
||||
parser.add_argument("--test-json", required=True)
|
||||
parser.add_argument("--duration", required=True)
|
||||
parser.add_argument("--report", required=True)
|
||||
parser.add_argument("--min-coverage", type=float, default=65.0)
|
||||
parser.add_argument("--max-duration", type=float, required=True)
|
||||
parser.add_argument("--test-exit-code", type=int, default=0)
|
||||
parser.add_argument("--critical-package", action="append", default=[])
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
failures = []
|
||||
try:
|
||||
total, files = coverage(args.coverage)
|
||||
total_tests, packages = test_evidence(args.test_json)
|
||||
elapsed = float(Path(args.duration).read_text(encoding="utf-8").strip())
|
||||
except (OSError, ValueError) as error:
|
||||
failures.append(str(error))
|
||||
total, files = [0, 0], {}
|
||||
total_tests, packages, elapsed = {"pass": 0, "fail": 0, "skip": 0}, {}, None
|
||||
|
||||
percent = total[1] / total[0] * 100 if total[0] else 0.0
|
||||
if percent < args.min_coverage:
|
||||
failures.append(f"overall coverage {percent:.2f}% is below {args.min_coverage:.2f}%")
|
||||
if args.test_exit_code:
|
||||
failures.append(f"go test exited with {args.test_exit_code}")
|
||||
if total_tests["fail"]:
|
||||
failures.append(f"test evidence contains {total_tests['fail']} failed test events")
|
||||
if elapsed is not None and elapsed > args.max_duration:
|
||||
failures.append(f"test duration {elapsed:.2f}s exceeds {args.max_duration:.2f}s")
|
||||
if sum(total_tests.values()) == 0:
|
||||
failures.append("test evidence contains no test results")
|
||||
|
||||
critical, critical_coverage = [], []
|
||||
for spec in args.critical_package:
|
||||
matched = {
|
||||
name: values for name, values in packages.items() if package_matches(name, spec)
|
||||
}
|
||||
item = {
|
||||
"package": spec,
|
||||
"matched": sorted(matched),
|
||||
"passed": sum(values["pass"] for values in matched.values()),
|
||||
"failed": sum(values["fail"] for values in matched.values()),
|
||||
"skipped": sum(values["skip"] for values in matched.values()),
|
||||
}
|
||||
critical.append(item)
|
||||
if not matched:
|
||||
failures.append(f"critical suite {spec} produced no package evidence")
|
||||
elif item["passed"] == 0:
|
||||
failures.append(f"critical suite {spec} was entirely skipped")
|
||||
|
||||
matching = [
|
||||
values
|
||||
for name, values in files.items()
|
||||
if package_matches(name.rsplit("/", 1)[0], spec)
|
||||
]
|
||||
statements = sum(values[0] for values in matching)
|
||||
covered = sum(values[1] for values in matching)
|
||||
coverage_item = {
|
||||
"package": spec,
|
||||
"covered_statements": covered,
|
||||
"total_statements": statements,
|
||||
"percent": round(covered / statements * 100, 2) if statements else 0.0,
|
||||
}
|
||||
critical_coverage.append(coverage_item)
|
||||
if not statements:
|
||||
failures.append(f"critical coverage {spec} has no statements")
|
||||
elif coverage_item["percent"] < args.min_coverage:
|
||||
failures.append(
|
||||
f"critical coverage {spec} {coverage_item['percent']:.2f}% "
|
||||
f"is below {args.min_coverage:.2f}%"
|
||||
)
|
||||
|
||||
report = {
|
||||
"schema_version": 1,
|
||||
"status": "failed" if failures else "passed",
|
||||
"thresholds": {
|
||||
"min_coverage_percent": args.min_coverage,
|
||||
"max_duration_seconds": args.max_duration,
|
||||
},
|
||||
"coverage": {
|
||||
"covered_statements": total[1],
|
||||
"total_statements": total[0],
|
||||
"percent": round(percent, 2),
|
||||
"critical": critical_coverage,
|
||||
"files": {
|
||||
name: {
|
||||
"covered_statements": values[1],
|
||||
"total_statements": values[0],
|
||||
"percent": round(values[1] / values[0] * 100, 2) if values[0] else 0.0,
|
||||
}
|
||||
for name, values in sorted(files.items())
|
||||
},
|
||||
},
|
||||
"tests": {**total_tests, "critical": critical},
|
||||
"duration_seconds": elapsed,
|
||||
"test_exit_code": args.test_exit_code,
|
||||
"failures": failures,
|
||||
}
|
||||
report_path = Path(args.report)
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": report["status"],
|
||||
"coverage_percent": report["coverage"]["percent"],
|
||||
"tests": report["tests"],
|
||||
"duration_seconds": elapsed,
|
||||
"failures": failures,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run())
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
QUALITY_DIR="${GOCHAT_QUALITY_DIR:-$ROOT/.tmp/quality}"
|
||||
mkdir -p "$QUALITY_DIR"
|
||||
|
||||
RACE="${GOCHAT_RACE:-true}"
|
||||
COLD_CACHE="${GOCHAT_COLD_CACHE:-true}"
|
||||
MIN_COVERAGE="${GOCHAT_MIN_COVERAGE:-65}"
|
||||
MAX_DURATION="${GOCHAT_MAX_DURATION_SECONDS:-300}"
|
||||
COVERAGE_FILE="${GOCHAT_COVERAGE_FILE:-$QUALITY_DIR/coverage.out}"
|
||||
TEST_JSON="${GOCHAT_TEST_JSON:-$QUALITY_DIR/test.jsonl}"
|
||||
DURATION_FILE="${GOCHAT_DURATION_FILE:-$QUALITY_DIR/duration.seconds}"
|
||||
REPORT_FILE="${GOCHAT_QUALITY_REPORT:-$QUALITY_DIR/quality-gate.json}"
|
||||
|
||||
packages=(./internal/... ./pkg/... ./cmd/...)
|
||||
if [[ -n "${GOCHAT_TEST_PACKAGES:-}" ]]; then
|
||||
read -r -a packages <<< "$GOCHAT_TEST_PACKAGES"
|
||||
fi
|
||||
|
||||
if [[ "$COLD_CACHE" == "true" ]]; then
|
||||
go clean -testcache
|
||||
fi
|
||||
|
||||
race_args=()
|
||||
if [[ "$RACE" == "true" ]]; then
|
||||
race_args=(-race)
|
||||
fi
|
||||
|
||||
start="$(date +%s.%N)"
|
||||
set +e
|
||||
go test -json -count=1 "${race_args[@]}" -covermode=atomic \
|
||||
-coverprofile="$COVERAGE_FILE" -timeout "${GOCHAT_TEST_TIMEOUT:-300s}" \
|
||||
"${packages[@]}" | tee "$TEST_JSON" >/dev/null
|
||||
test_status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
end="$(date +%s.%N)"
|
||||
awk -v start="$start" -v end="$end" 'BEGIN { printf "%.3f\n", end-start }' > "$DURATION_FILE"
|
||||
|
||||
set +e
|
||||
python3 "$ROOT/scripts/ci_quality_gate.py" \
|
||||
--coverage "$COVERAGE_FILE" \
|
||||
--test-json "$TEST_JSON" \
|
||||
--duration "$DURATION_FILE" \
|
||||
--report "$REPORT_FILE" \
|
||||
--min-coverage "$MIN_COVERAGE" \
|
||||
--max-duration "$MAX_DURATION" \
|
||||
--test-exit-code "$test_status" \
|
||||
--critical-package github.com/gochat/gochat/internal/ws \
|
||||
--critical-package github.com/gochat/gochat/internal/handler/ws \
|
||||
--critical-package github.com/gochat/gochat/internal/worker \
|
||||
--critical-package github.com/gochat/gochat/internal/channel/...
|
||||
gate_status=$?
|
||||
set -e
|
||||
|
||||
[[ "$test_status" -eq 0 ]] || exit "$test_status"
|
||||
exit "$gate_status"
|
||||
@@ -1,6 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import {
|
||||
closeSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -20,6 +27,39 @@ const frontendBaseURL = `http://${shellHost}:${shellPort}`;
|
||||
const apiBaseURL = `http://${apiHost}:${apiPort}`;
|
||||
const enterpriseMode = process.argv.includes('--enterprise');
|
||||
|
||||
function isSuccessfulRequest(request, substring) {
|
||||
return request.url.includes(substring) && request.status >= 200 && request.status < 400;
|
||||
}
|
||||
|
||||
function isFailedAPIRequest(request) {
|
||||
const isBackendAPI = request.url.includes(apiBaseURL);
|
||||
const isShellProxiedAPI = request.url.startsWith(frontendBaseURL) && (
|
||||
request.url.includes('/api/') ||
|
||||
request.url.includes('/enterprise/') ||
|
||||
request.url.includes('/public/') ||
|
||||
request.url.includes('/auth/') ||
|
||||
request.url.includes('/rails/')
|
||||
);
|
||||
if (!isBackendAPI && !isShellProxiedAPI || request.type === 'Preflight') return false;
|
||||
const isNavigationAbort = request.type === 'Document' &&
|
||||
['net::ERR_ABORTED', 'net::ERR_FAILED'].includes(request.errorText);
|
||||
return request.status >= 400 || request.status === 0 && !isNavigationAbort;
|
||||
}
|
||||
|
||||
if (process.argv.includes('--self-test')) {
|
||||
const apiURL = `${apiBaseURL}/api/v1/profile`;
|
||||
const checks = [
|
||||
isSuccessfulRequest({ url: apiURL, status: 200 }, '/api/v1/profile'),
|
||||
!isSuccessfulRequest({ url: apiURL, status: 500 }, '/api/v1/profile'),
|
||||
isFailedAPIRequest({ url: apiURL, status: 0, type: 'Fetch', errorText: 'net::ERR_FAILED' }),
|
||||
!isFailedAPIRequest({ url: apiURL, status: 0, type: 'Document', errorText: 'net::ERR_ABORTED' }),
|
||||
!isFailedAPIRequest({ url: `${frontendBaseURL}/favicon.ico`, status: 404, type: 'Image' }),
|
||||
];
|
||||
if (checks.some(check => !check)) throw new Error('browser smoke failure contract self-test failed');
|
||||
console.log('browser smoke failure contract self-test: ok');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
|
||||
const seed = JSON.parse(readFileSync(path.join(logDir, 'seed.json'), 'utf8'));
|
||||
@@ -38,7 +78,7 @@ const report = {
|
||||
|
||||
function smokeHTML(entrypoint, route) {
|
||||
const config = {
|
||||
apiHost: apiBaseURL,
|
||||
apiHost: '',
|
||||
hostURL: frontendBaseURL,
|
||||
helpCenterURL: '',
|
||||
allowedLoginMethods: ['email'],
|
||||
@@ -78,7 +118,7 @@ function smokeHTML(entrypoint, route) {
|
||||
window.errorLoggingConfig = '';
|
||||
window.analyticsConfig = { token: '' };
|
||||
</script>
|
||||
<script type="module" src="/vite-dev/entrypoints/${entrypoint}.js"></script>
|
||||
<script type="module" src="${viteBaseURL}/app/javascript/entrypoints/${entrypoint}.js"></script>
|
||||
</head>
|
||||
<body class="text-slate-600"><div id="app"></div></body>
|
||||
</html>`;
|
||||
@@ -114,7 +154,7 @@ function widgetSmokeHTML(route) {
|
||||
window.browserConfig = { browser_name: 'chrome' };
|
||||
window.errorLoggingConfig = '';
|
||||
</script>
|
||||
<script type="module" src="/vite-dev/entrypoints/widget.js"></script>
|
||||
<script type="module" src="${viteBaseURL}/app/javascript/entrypoints/widget.js"></script>
|
||||
</head>
|
||||
<body class="text-slate-600"><div id="app"></div></body>
|
||||
</html>`;
|
||||
@@ -393,6 +433,7 @@ class CDPPage {
|
||||
report.requests.push({
|
||||
url: this.requestURLs.get(params.requestId) || params.requestId,
|
||||
status: 0,
|
||||
type: params.type,
|
||||
errorText: params.errorText,
|
||||
});
|
||||
});
|
||||
@@ -456,14 +497,14 @@ class CDPPage {
|
||||
);
|
||||
}
|
||||
|
||||
async waitForCapturedRequest(substring, label, timeout = 30000) {
|
||||
return this.waitForCapturedRequestAfter(substring, 0, label, timeout);
|
||||
async waitForSuccessfulRequest(substring, label, timeout = 30000) {
|
||||
return this.waitForSuccessfulRequestAfter(substring, 0, label, timeout);
|
||||
}
|
||||
|
||||
async waitForCapturedRequestAfter(substring, requestIndex, label, timeout = 30000) {
|
||||
async waitForSuccessfulRequestAfter(substring, requestIndex, label, timeout = 30000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
if (report.requests.slice(requestIndex).some(request => request.url.includes(substring))) {
|
||||
if (report.requests.slice(requestIndex).some(request => isSuccessfulRequest(request, substring))) {
|
||||
report.checks.push({ label, status: 'passed' });
|
||||
return;
|
||||
}
|
||||
@@ -473,22 +514,7 @@ class CDPPage {
|
||||
}
|
||||
|
||||
assertNoFailedAPIRequests(requestIndex = 0) {
|
||||
const failures = report.requests.slice(requestIndex).filter(request => {
|
||||
const isBackendAPI = request.url.includes(apiBaseURL);
|
||||
const isShellProxiedAPI = request.url.startsWith(frontendBaseURL) && (
|
||||
request.url.includes('/api/') ||
|
||||
request.url.includes('/enterprise/') ||
|
||||
request.url.includes('/public/') ||
|
||||
request.url.includes('/auth/') ||
|
||||
request.url.includes('/rails/')
|
||||
);
|
||||
if (!isBackendAPI && !isShellProxiedAPI) return false;
|
||||
if (request.type === 'Preflight') return false;
|
||||
if (request.status === 0 && ['net::ERR_ABORTED', 'net::ERR_FAILED'].includes(request.errorText)) {
|
||||
return false;
|
||||
}
|
||||
return request.status >= 400 || request.status === 0;
|
||||
});
|
||||
const failures = report.requests.slice(requestIndex).filter(isFailedAPIRequest);
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Frontend API requests failed: ${failures.map(request => `${request.status} ${request.url}`).join('; ')}`);
|
||||
}
|
||||
@@ -502,17 +528,33 @@ class CDPPage {
|
||||
}
|
||||
|
||||
async function launchChrome() {
|
||||
if (typeof WebSocket !== 'function') {
|
||||
throw new Error('Node.js WebSocket API is unavailable; use --experimental-websocket');
|
||||
}
|
||||
const userDataDir = mkdtempSync(path.join(tmpdir(), 'gochat-chrome-'));
|
||||
const chrome = spawn(chromePath, [
|
||||
const args = [
|
||||
'--headless=new',
|
||||
'--disable-gpu',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-dev-shm-usage',
|
||||
'--enable-logging=stderr',
|
||||
'--remote-debugging-port=0',
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
'about:blank',
|
||||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
];
|
||||
if (process.env.CI) args.push('--no-sandbox');
|
||||
args.push('about:blank');
|
||||
const chromeLogPath = path.join(logDir, 'chrome.log');
|
||||
const chromeLog = openSync(chromeLogPath, 'w');
|
||||
const chrome = spawn(chromePath, args, { stdio: ['ignore', 'ignore', chromeLog] });
|
||||
closeSync(chromeLog);
|
||||
let chromeFailure = '';
|
||||
chrome.once('error', error => {
|
||||
chromeFailure = `failed to launch Chrome: ${error.message}`;
|
||||
});
|
||||
chrome.once('exit', (code, signal) => {
|
||||
chromeFailure = `Chrome exited before DevTools was ready (code=${code}, signal=${signal})`;
|
||||
});
|
||||
const portFile = path.join(userDataDir, 'DevToolsActivePort');
|
||||
for (let i = 0; i < 80; i += 1) {
|
||||
try {
|
||||
@@ -527,11 +569,12 @@ async function launchChrome() {
|
||||
await page.init();
|
||||
return page;
|
||||
} catch {
|
||||
if (chromeFailure) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
chrome.kill('SIGTERM');
|
||||
throw new Error('Chrome DevTools did not become ready');
|
||||
throw new Error(`${chromeFailure || 'Chrome DevTools did not become ready'}; see ${chromeLogPath}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -547,17 +590,17 @@ async function main() {
|
||||
|
||||
await page.navigate(smokePages.dashboard);
|
||||
await page.waitForAppMounted('dashboard app mounted');
|
||||
await page.waitForCapturedRequest('/auth/validate_token', 'dashboard validates auth token');
|
||||
await page.waitForCapturedRequest(`/api/v1/accounts/${seed.account_id}/conversations`, 'dashboard requests conversations');
|
||||
await page.waitForSuccessfulRequest('/auth/validate_token', 'dashboard validates auth token');
|
||||
await page.waitForSuccessfulRequest(`/api/v1/accounts/${seed.account_id}/conversations`, 'dashboard requests conversations');
|
||||
page.assertNoFailedAPIRequests();
|
||||
|
||||
const widgetRequestIndex = report.requests.length;
|
||||
await page.navigate(smokePages.widget);
|
||||
await page.waitForAppMounted('widget app mounted');
|
||||
await page.waitForCapturedRequest('/api/v1/widget/messages', 'widget requests messages');
|
||||
await page.waitForCapturedRequest('/api/v1/widget/inbox_members', 'widget requests inbox members');
|
||||
await page.waitForSuccessfulRequest('/api/v1/widget/messages', 'widget requests messages');
|
||||
await page.waitForSuccessfulRequest('/api/v1/widget/inbox_members', 'widget requests inbox members');
|
||||
await page.eval(`fetch('/api/v1/widget/campaigns?website_token=${encodeURIComponent(widgetConfig.website_channel_config?.website_token || 'gochat-smoke-widget-token')}').then(response => response.ok)`);
|
||||
await page.waitForCapturedRequest('/api/v1/widget/campaigns', 'widget campaigns endpoint works');
|
||||
await page.waitForSuccessfulRequest('/api/v1/widget/campaigns', 'widget campaigns endpoint works');
|
||||
page.assertNoFailedAPIRequests(widgetRequestIndex);
|
||||
|
||||
if (enterpriseMode) {
|
||||
@@ -566,7 +609,7 @@ async function main() {
|
||||
await page.navigate(enterprisePage.url);
|
||||
await page.waitForAppMounted(`${enterprisePage.label} app mounted`);
|
||||
for (const request of enterprisePage.requests) {
|
||||
await page.waitForCapturedRequestAfter(request, requestIndex, `${enterprisePage.label} requests ${request}`);
|
||||
await page.waitForSuccessfulRequestAfter(request, requestIndex, `${enterprisePage.label} requests ${request}`);
|
||||
}
|
||||
page.assertNoFailedAPIRequests(requestIndex);
|
||||
}
|
||||
@@ -586,7 +629,7 @@ async function main() {
|
||||
})(),
|
||||
body: JSON.stringify({ message: 'B12 enterprise browser copilot smoke', assistant_id: ${Number(seed.captain_assistant_id)}, conversation_id: ${Number(seed.conversation_id)} })
|
||||
}).then(response => response.ok)`);
|
||||
await page.waitForCapturedRequestAfter('/captain/copilot_threads', copilotRequestIndex, 'browser context requests Copilot threads');
|
||||
await page.waitForSuccessfulRequestAfter('/captain/copilot_threads', copilotRequestIndex, 'browser context requests Copilot threads');
|
||||
page.assertNoFailedAPIRequests(copilotRequestIndex);
|
||||
}
|
||||
report.finished_at = new Date().toISOString();
|
||||
|
||||
@@ -478,7 +478,7 @@ SEED
|
||||
|
||||
run_browser_smoke() {
|
||||
run_api_smoke
|
||||
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/vite-dev/entrypoints/dashboard.js" "Chatwoot Vite"
|
||||
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/app/javascript/entrypoints/dashboard.js" "Chatwoot Vite"
|
||||
GOCHAT_ROOT="$ROOT" \
|
||||
CHATWOOT_DIR="$CHATWOOT_DIR" \
|
||||
GOCHAT_SMOKE_LOG_DIR="$LOG_DIR" \
|
||||
@@ -486,7 +486,7 @@ run_browser_smoke() {
|
||||
GOCHAT_SMOKE_API_PORT="$API_PORT" \
|
||||
GOCHAT_SMOKE_FRONTEND_HOST="$FRONTEND_HOST" \
|
||||
GOCHAT_SMOKE_FRONTEND_PORT="$FRONTEND_PORT" \
|
||||
node "$ROOT/scripts/parity_frontend_browser_smoke.mjs"
|
||||
node --experimental-websocket "$ROOT/scripts/parity_frontend_browser_smoke.mjs"
|
||||
BOOT_BACKEND_RESULT="Passed /health during browser smoke" \
|
||||
BOOT_FRONTEND_RESULT="Passed Vite readiness during browser smoke" \
|
||||
AUTH_PROFILE_RESULT="Passed browser login plus API smoke" \
|
||||
@@ -793,7 +793,7 @@ wait_url "http://$API_HOST:$API_PORT/health" "GoChat"
|
||||
|
||||
echo "starting reused Chatwoot frontend..."
|
||||
(cd "$CHATWOOT_DIR" && "${frontend_cmd[@]}") >"$LOG_DIR/chatwoot-vite.log" 2>&1 &
|
||||
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/vite-dev/entrypoints/dashboard.js" "Chatwoot Vite"
|
||||
wait_url "http://$FRONTEND_HOST:$FRONTEND_PORT/app/javascript/entrypoints/dashboard.js" "Chatwoot Vite"
|
||||
|
||||
BOOT_BACKEND_RESULT="Passed boot readiness" \
|
||||
BOOT_FRONTEND_RESULT="Passed boot readiness" \
|
||||
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Failure-injection checks for CI quality gates."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
GATE = ROOT / "ci_quality_gate.py"
|
||||
HEALTH = ROOT / "validate_health_json.py"
|
||||
|
||||
|
||||
def run(command):
|
||||
return subprocess.run(command, capture_output=True, text=True)
|
||||
|
||||
|
||||
def write_fixtures(
|
||||
directory,
|
||||
coverage_lines=(
|
||||
"github.com/gochat/gochat/internal/ws/file.go:1.1,1.2 2 1\n"
|
||||
"github.com/gochat/gochat/internal/ws/file.go:2.1,2.2 2 1\n"
|
||||
),
|
||||
test_lines=None,
|
||||
duration="1",
|
||||
):
|
||||
(directory / "coverage.out").write_text("mode: atomic\n" + coverage_lines, encoding="utf-8")
|
||||
events = test_lines or [
|
||||
{"Action": "pass", "Package": "github.com/gochat/gochat/internal/ws", "Test": "TestRelay"}
|
||||
]
|
||||
(directory / "test.jsonl").write_text(
|
||||
"\n".join(json.dumps(event) for event in events) + "\n", encoding="utf-8"
|
||||
)
|
||||
(directory / "duration").write_text(duration, encoding="utf-8")
|
||||
|
||||
|
||||
def gate(directory, budget=5, exit_code=0):
|
||||
return run(
|
||||
[
|
||||
sys.executable,
|
||||
str(GATE),
|
||||
"--coverage",
|
||||
str(directory / "coverage.out"),
|
||||
"--test-json",
|
||||
str(directory / "test.jsonl"),
|
||||
"--duration",
|
||||
str(directory / "duration"),
|
||||
"--report",
|
||||
str(directory / "report.json"),
|
||||
"--min-coverage",
|
||||
"65",
|
||||
"--max-duration",
|
||||
str(budget),
|
||||
"--test-exit-code",
|
||||
str(exit_code),
|
||||
"--critical-package",
|
||||
"github.com/gochat/gochat/internal/ws",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
directory = Path(temp)
|
||||
write_fixtures(directory)
|
||||
assert gate(directory).returncode == 0
|
||||
|
||||
write_fixtures(
|
||||
directory,
|
||||
"github.com/gochat/gochat/internal/ws/file.go:1.1,1.2 2 1\n"
|
||||
"github.com/gochat/gochat/internal/ws/file.go:2.1,2.2 2 0\n",
|
||||
)
|
||||
assert gate(directory).returncode != 0, "coverage regression must fail"
|
||||
|
||||
write_fixtures(
|
||||
directory,
|
||||
test_lines=[
|
||||
{"Action": "skip", "Package": "github.com/gochat/gochat/internal/ws", "Test": "TestRelay"}
|
||||
],
|
||||
)
|
||||
assert gate(directory).returncode != 0, "all-skipped critical suite must fail"
|
||||
|
||||
write_fixtures(
|
||||
directory,
|
||||
"github.com/gochat/gochat/internal/wsevent/file.go:1.1,1.2 2 1\n",
|
||||
[{"Action": "pass", "Package": "github.com/gochat/gochat/internal/wsevent", "Test": "TestBridge"}],
|
||||
)
|
||||
assert gate(directory).returncode != 0, "sibling package must not satisfy the ws gate"
|
||||
|
||||
write_fixtures(directory, duration="6")
|
||||
assert gate(directory).returncode != 0, "duration budget must fail"
|
||||
|
||||
write_fixtures(
|
||||
directory,
|
||||
test_lines=[
|
||||
{"Action": "fail", "Package": "github.com/gochat/gochat/internal/ws", "Test": "TestRelay"}
|
||||
],
|
||||
)
|
||||
assert gate(directory).returncode != 0, "failed test evidence must fail"
|
||||
|
||||
write_fixtures(directory)
|
||||
assert gate(directory, exit_code=2).returncode != 0, "non-zero go test status must fail"
|
||||
|
||||
health = directory / "health.json"
|
||||
health.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2026-08-22T00:00:00Z",
|
||||
"uptime": "1s",
|
||||
"checks": {"database": "healthy"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert run([sys.executable, str(HEALTH), str(health)]).returncode == 0
|
||||
health.write_text("{broken", encoding="utf-8")
|
||||
assert run([sys.executable, str(HEALTH), str(health)]).returncode != 0, "invalid health JSON must fail"
|
||||
health.write_text(json.dumps({"status": "unhealthy"}), encoding="utf-8")
|
||||
assert run([sys.executable, str(HEALTH), str(health)]).returncode != 0, "unhealthy contract must fail"
|
||||
|
||||
print("quality gate failure-injection checks: ok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the stable JSON contract returned by GET /health."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def validate(value):
|
||||
if not isinstance(value, dict):
|
||||
return "health response must be a JSON object"
|
||||
missing = sorted({"status", "timestamp", "uptime", "checks"} - value.keys())
|
||||
if missing:
|
||||
return f"health response missing fields: {', '.join(missing)}"
|
||||
if value["status"] != "healthy":
|
||||
return f"unexpected health status: {value['status']}"
|
||||
try:
|
||||
datetime.fromisoformat(value["timestamp"].replace("Z", "+00:00"))
|
||||
except (AttributeError, ValueError):
|
||||
return "timestamp must be RFC3339"
|
||||
if not isinstance(value["uptime"], str) or not value["uptime"]:
|
||||
return "uptime must be a non-empty string"
|
||||
if not isinstance(value["checks"], dict) or value["checks"].get("database") != "healthy":
|
||||
return "database health check must be healthy"
|
||||
return None
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("path", nargs="?", help="JSON file; stdin when omitted")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
raw = Path(args.path).read_text(encoding="utf-8") if args.path else sys.stdin.read()
|
||||
error = validate(json.loads(raw))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
error = f"invalid health JSON: {exc}"
|
||||
if error:
|
||||
print(error, file=sys.stderr)
|
||||
return 1
|
||||
print("health JSON contract: ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user