@mcptoolshop/accessibility-suite 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.a11y_artifacts_test/evidence.json +52 -0
- package/.a11y_artifacts_test/gate-result.json +41 -0
- package/.a11y_artifacts_test/report.txt +19 -0
- package/.github/actions/a11y-ci/action.yml +106 -0
- package/.github/workflows/a11y-gate.yml +112 -0
- package/.github/workflows/ci.yml +68 -3
- package/.github/workflows/test-a11y-action.yml +93 -0
- package/.github/workflows/update-baseline.yml +49 -0
- package/.github/workflows/verify-docs.yml +26 -0
- package/CHANGELOG.md +33 -0
- package/GETTING_STARTED.md +87 -0
- package/HANDBOOK.md +747 -0
- package/README.md +202 -23
- package/assets/a11y-logo.png +0 -0
- package/docs/handbooks/A11Y-ASSIST.md +31 -0
- package/docs/handbooks/A11Y-CI.md +71 -0
- package/docs/handbooks/A11Y-DEMO-SITE.md +29 -0
- package/docs/handbooks/A11Y-EVIDENCE-ENGINE.md +31 -0
- package/docs/handbooks/A11Y-LINT.md +62 -0
- package/docs/handbooks/A11Y-MCP-TOOLS.md +34 -0
- package/docs/handbooks/ACCESSIBILITY-SUITE.md +51 -0
- package/docs/handbooks/ALLY-DEMO-PYTHON.md +23 -0
- package/docs/handbooks/COMMON-CONCEPTS.md +24 -0
- package/docs/handbooks/CURSORASSIST.md +18 -0
- package/docs/handbooks/README.md +20 -0
- package/docs/prov-spec/SETUP.md +1 -1
- package/docs/rules.md +132 -0
- package/docs/unified-artifacts.md +52 -0
- package/logo.png +0 -0
- package/package.json +1 -1
- package/pipelines/templates/a11y-ci.yml +135 -0
- package/pipelines/test-a11y-ci-template.yml +36 -0
- package/scripts/verify_handbooks.py +97 -0
- package/src/a11y-assist/README.md +5 -0
- package/src/a11y-ci/.a11y_artifacts_test/current.scorecard.json +11 -0
- package/src/a11y-ci/.a11y_artifacts_test/evidence.json +52 -0
- package/src/a11y-ci/.a11y_artifacts_test/gate-result.json +41 -0
- package/src/a11y-ci/.a11y_artifacts_test/report.txt +19 -0
- package/src/a11y-ci/README.md +83 -23
- package/src/a11y-ci/a11y_ci/allowlist.py +52 -9
- package/src/a11y-ci/a11y_ci/cli.py +143 -46
- package/src/a11y-ci/a11y_ci/error_ids.py +17 -0
- package/src/a11y-ci/a11y_ci/gate.py +83 -48
- package/src/a11y-ci/a11y_ci/help.py +119 -0
- package/src/a11y-ci/a11y_ci/mcp_payload.py +124 -0
- package/src/a11y-ci/a11y_ci/pr_comment.py +127 -0
- package/src/a11y-ci/a11y_ci/report.py +137 -0
- package/src/a11y-ci/a11y_ci/schema/scorecard.schema.json +89 -0
- package/src/a11y-ci/a11y_ci/schemas/allowlist.schema.json +11 -2
- package/src/a11y-ci/a11y_ci/scorecard.py +86 -30
- package/src/a11y-ci/a11y_ci/severity.py +29 -0
- package/src/a11y-ci/npm/README.md +47 -0
- package/src/a11y-ci/npm/package.json +1 -1
- package/src/a11y-ci/tests/fixtures/allowlist_expired.json +2 -1
- package/src/a11y-ci/tests/fixtures/allowlist_ok.json +2 -1
- package/src/a11y-ci/tests/fixtures/baseline_ok.json +17 -4
- package/src/a11y-ci/tests/fixtures/current_fail.json +10 -3
- package/src/a11y-ci/tests/fixtures/current_failures_many.json +11 -0
- package/src/a11y-ci/tests/fixtures/current_ok.json +10 -3
- package/src/a11y-ci/tests/fixtures/current_regresses.json +15 -4
- package/src/a11y-ci/tests/test_allowlist_v2.py +97 -0
- package/src/a11y-ci/tests/test_gate.py +3 -3
- package/src/a11y-ci/tests/test_mcp_cli.py +80 -0
- package/src/a11y-ci/tests/test_mcp_payload.py +76 -0
- package/src/a11y-ci/tests/test_polish.py +83 -0
- package/src/a11y-ci/tests/test_pr_comment.py +103 -0
- package/src/a11y-ci/tests/test_rule_help.py +70 -0
- package/src/a11y-ci/tests/test_schema_validation.py +36 -0
- package/src/a11y-ci/tests/test_scorecard_canonical.py +88 -0
- package/src/a11y-ci/tests/test_smoke_cli.py +41 -0
- package/src/a11y-evidence-engine/README.md +5 -0
- package/src/a11y-lint/README.md +5 -0
- package/src/a11y-lint/a11y_lint/cli.py +29 -0
- package/src/a11y-mcp-tools/README.md +5 -0
- package/tools/ado/a11y-ci.ps1 +195 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
|
|
2
|
+
import json
|
|
3
|
+
import pytest
|
|
4
|
+
from click.testing import CliRunner
|
|
5
|
+
from a11y_ci.cli import main
|
|
6
|
+
from a11y_ci.pr_comment import render_pr_comment
|
|
7
|
+
|
|
8
|
+
FIXTURES = "tests/fixtures"
|
|
9
|
+
|
|
10
|
+
def test_gate_top_flag():
|
|
11
|
+
"""Test --top limits the output lines."""
|
|
12
|
+
runner = CliRunner()
|
|
13
|
+
result = runner.invoke(main, [
|
|
14
|
+
"gate",
|
|
15
|
+
"--current", f"{FIXTURES}/current_failures_many.json",
|
|
16
|
+
"--fail-on", "minor", # Ensure we have failures
|
|
17
|
+
"--top", "1"
|
|
18
|
+
])
|
|
19
|
+
assert result.exit_code == 3
|
|
20
|
+
# Should see the first blocking ID
|
|
21
|
+
assert "Blocking IDs (Top 1):" in result.output
|
|
22
|
+
# Should see "and X more"
|
|
23
|
+
assert "... and 2 more" in result.output
|
|
24
|
+
|
|
25
|
+
def test_comment_top_flag():
|
|
26
|
+
"""Test --top limits PR comment output."""
|
|
27
|
+
payload = {
|
|
28
|
+
"gate": {"decision": "fail"},
|
|
29
|
+
"blocking": [{"id": f"ID.{i}", "severity": "serious"} for i in range(20)]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# IDs will sort string-wise: ID.0, ID.1, ID.10, ID.11, ID.12 ...
|
|
33
|
+
# So top 5 are: ID.0, ID.1, ID.10, ID.11, ID.12
|
|
34
|
+
|
|
35
|
+
# Render with top=5
|
|
36
|
+
md = render_pr_comment(payload, top=5)
|
|
37
|
+
assert "_Showing first 5 of 20 violations_" in md
|
|
38
|
+
|
|
39
|
+
assert "ID.0" in md
|
|
40
|
+
assert "ID.12" in md
|
|
41
|
+
assert "ID.2" not in md # Comes later lexicographically
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_graceful_missing_fields():
|
|
45
|
+
"""Render PR comment with minimal payload should not crash."""
|
|
46
|
+
minimal = {
|
|
47
|
+
"gate": {}
|
|
48
|
+
# Missing blocking, artifacts, counts, etc.
|
|
49
|
+
}
|
|
50
|
+
md = render_pr_comment(minimal)
|
|
51
|
+
assert "# ❌ Accessibility Gate: FAIL" in md # default decision unknown -> fail
|
|
52
|
+
assert "| Findings | 0 | - |" in md
|
|
53
|
+
|
|
54
|
+
# Finding without optional fields
|
|
55
|
+
payload_partial = {
|
|
56
|
+
"gate": {"decision": "fail"},
|
|
57
|
+
"blocking": [{"id": "A"}] # Missing severity, message, location
|
|
58
|
+
}
|
|
59
|
+
md = render_pr_comment(payload_partial)
|
|
60
|
+
assert "### [INFO] A" in md # default severity info
|
|
61
|
+
assert "No message provided" in md
|
|
62
|
+
assert "Unknown location" in md
|
|
63
|
+
|
|
64
|
+
def test_determinism(tmp_path):
|
|
65
|
+
"""Running gate twice on same input should produce identical output."""
|
|
66
|
+
runner = CliRunner()
|
|
67
|
+
|
|
68
|
+
# Run 1
|
|
69
|
+
res1 = runner.invoke(main, [
|
|
70
|
+
"gate",
|
|
71
|
+
"--current", f"{FIXTURES}/current_fail.json",
|
|
72
|
+
"--format", "json"
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
# Run 2
|
|
76
|
+
res2 = runner.invoke(main, [
|
|
77
|
+
"gate",
|
|
78
|
+
"--current", f"{FIXTURES}/current_fail.json",
|
|
79
|
+
"--format", "json"
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
assert res1.exit_code == res2.exit_code
|
|
83
|
+
assert res1.output == res2.output
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
|
|
2
|
+
import json
|
|
3
|
+
import pytest
|
|
4
|
+
from a11y_ci.pr_comment import render_pr_comment
|
|
5
|
+
|
|
6
|
+
@pytest.fixture
|
|
7
|
+
def sample_payload():
|
|
8
|
+
return {
|
|
9
|
+
"tool": "a11y-ci",
|
|
10
|
+
"tool_version": "1.0.0",
|
|
11
|
+
"repo": "org/repo",
|
|
12
|
+
"commit_sha": "abcdef123456",
|
|
13
|
+
"run_id": "run-xyz-789",
|
|
14
|
+
"gate": {
|
|
15
|
+
"decision": "fail",
|
|
16
|
+
"fail_on": "serious",
|
|
17
|
+
"counts": {"serious": 2, "minor": 5},
|
|
18
|
+
"deltas": {"serious": 2}
|
|
19
|
+
},
|
|
20
|
+
"blocking": [
|
|
21
|
+
{
|
|
22
|
+
"id": "A11Y.IMG.ALT",
|
|
23
|
+
"severity": "serious",
|
|
24
|
+
"message": "Missing alt text",
|
|
25
|
+
"location": "logo.png",
|
|
26
|
+
"help_hint": "Add alt='logo'",
|
|
27
|
+
"help_url": "http://docs/img-alt"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "A11Y.BTN.NAME",
|
|
31
|
+
"severity": "critical",
|
|
32
|
+
"message": "Button empty",
|
|
33
|
+
"location": "btn.js"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"artifacts": [
|
|
37
|
+
{"kind": "scorecard", "sha256": "aabbccddeeff0011223344"}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def test_render_fail_github(sample_payload):
|
|
42
|
+
"""Test standard GitHub failure output."""
|
|
43
|
+
md = render_pr_comment(sample_payload, platform="github")
|
|
44
|
+
|
|
45
|
+
# Header
|
|
46
|
+
assert "❌ Accessibility Gate: FAIL" in md
|
|
47
|
+
|
|
48
|
+
# Table summary
|
|
49
|
+
assert "| Serious | 2 | +2 |" in md
|
|
50
|
+
assert "| Minor | 5 | - |" in md
|
|
51
|
+
|
|
52
|
+
# Blocking findings section
|
|
53
|
+
assert "## 🚫 Blocking Findings" in md
|
|
54
|
+
|
|
55
|
+
# Finding 1 (Critical should come first due to sorting)
|
|
56
|
+
assert "### [CRITICAL] A11Y.BTN.NAME" in md
|
|
57
|
+
assert "Button empty" in md
|
|
58
|
+
|
|
59
|
+
# Finding 2 (Serious)
|
|
60
|
+
assert "### [SERIOUS] A11Y.IMG.ALT" in md
|
|
61
|
+
assert "**Fix**: Add alt='logo'" in md
|
|
62
|
+
assert "**Docs**: [Read Guidance](http://docs/img-alt)" in md
|
|
63
|
+
|
|
64
|
+
# Artifacts
|
|
65
|
+
# hash is mock SHA 'aabbccddeeff0011223344', truncated to 12 chars + ...
|
|
66
|
+
assert "aabbccddeeff..." in md
|
|
67
|
+
|
|
68
|
+
# Footer
|
|
69
|
+
assert "Generated by **a11y-ci v1.0.0**" in md
|
|
70
|
+
assert "Commit: `abcdef1`" in md
|
|
71
|
+
|
|
72
|
+
def test_render_pass(sample_payload):
|
|
73
|
+
"""Test passing state."""
|
|
74
|
+
sample_payload["gate"]["decision"] = "pass"
|
|
75
|
+
sample_payload["blocking"] = []
|
|
76
|
+
|
|
77
|
+
md = render_pr_comment(sample_payload, platform="github")
|
|
78
|
+
|
|
79
|
+
assert "✅ Accessibility Gate: PASS" in md
|
|
80
|
+
assert "🚫 Blocking Findings" not in md
|
|
81
|
+
|
|
82
|
+
def test_render_ado_flavor(sample_payload):
|
|
83
|
+
"""Test ADO-specific nuances (mostly header changes in this simplified impl)."""
|
|
84
|
+
md = render_pr_comment(sample_payload, platform="ado")
|
|
85
|
+
|
|
86
|
+
# Check H3 format distinct from GitHub fixture
|
|
87
|
+
# GH: ### [CRITICAL] ID
|
|
88
|
+
# ADO: ### CRITICAL: ID
|
|
89
|
+
assert "### CRITICAL: A11Y.BTN.NAME" in md
|
|
90
|
+
assert "### [CRITICAL]" not in md
|
|
91
|
+
|
|
92
|
+
def test_render_missing_optional_fields():
|
|
93
|
+
"""Should handle missing deltas/counts gracefully."""
|
|
94
|
+
minimal_payload = {
|
|
95
|
+
"gate": {
|
|
96
|
+
"decision": "pass",
|
|
97
|
+
"fail_on": "error"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
md = render_pr_comment(minimal_payload)
|
|
101
|
+
assert "✅ Accessibility Gate: PASS" in md
|
|
102
|
+
# Should show 0 findings in table
|
|
103
|
+
assert "| Findings | 0 | - |" in md
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
|
|
2
|
+
import json
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from a11y_ci.help import get_help, HelpInfo
|
|
8
|
+
from a11y_ci.gate import GateResult
|
|
9
|
+
from a11y_ci.report import print_text_report, print_json_report
|
|
10
|
+
|
|
11
|
+
def test_registry_lookup():
|
|
12
|
+
"""Verify registry lookups handle case and whitespace."""
|
|
13
|
+
info = get_help(" a11y.img.alt ")
|
|
14
|
+
assert info is not None
|
|
15
|
+
assert info.title == "Missing Image Alt Text"
|
|
16
|
+
assert "add an 'alt'" in info.hint.lower()
|
|
17
|
+
|
|
18
|
+
# Missing
|
|
19
|
+
assert get_help("UNKOWN.RULE") is None
|
|
20
|
+
|
|
21
|
+
def test_report_includes_help_text(capsys):
|
|
22
|
+
"""Text report should include Fix and Docs lines for known rules."""
|
|
23
|
+
result = GateResult(
|
|
24
|
+
ok=False,
|
|
25
|
+
reasons=["Found errors"],
|
|
26
|
+
current_blocking_ids=["A11Y.IMG.ALT", "UNKNOWN.ID"],
|
|
27
|
+
new_blocking_ids=[],
|
|
28
|
+
current_counts={"serious": 1},
|
|
29
|
+
baseline_counts=None,
|
|
30
|
+
new_fingerprints=[]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
print_text_report(result)
|
|
34
|
+
captured = capsys.readouterr()
|
|
35
|
+
|
|
36
|
+
# Known rule -> Detailed Help
|
|
37
|
+
assert "A11Y.IMG.ALT" in captured.out
|
|
38
|
+
assert "Fix: Add an 'alt' attribute" in captured.out
|
|
39
|
+
assert "Docs: https://" in captured.out
|
|
40
|
+
|
|
41
|
+
# Unknown rule -> Simple listing
|
|
42
|
+
assert "UNKNOWN.ID" in captured.out
|
|
43
|
+
# Ideally should NOT show Fix/Docs for unknown
|
|
44
|
+
# But simple regex check might be tricky if one rule has it.
|
|
45
|
+
# We verify that we didn't crash at least.
|
|
46
|
+
|
|
47
|
+
def test_json_report_includes_help_fields(capsys):
|
|
48
|
+
"""JSON report should include help_url and help_hint."""
|
|
49
|
+
result = GateResult(
|
|
50
|
+
ok=False,
|
|
51
|
+
reasons=["Found errors"],
|
|
52
|
+
current_blocking_ids=["A11Y.IMG.ALT"],
|
|
53
|
+
new_blocking_ids=[],
|
|
54
|
+
current_counts={"serious": 1},
|
|
55
|
+
baseline_counts=None,
|
|
56
|
+
new_fingerprints=[]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
print_json_report(result)
|
|
60
|
+
captured = capsys.readouterr()
|
|
61
|
+
data = json.loads(captured.out)
|
|
62
|
+
|
|
63
|
+
details = data["blocking"]["details"]
|
|
64
|
+
assert len(details) == 1
|
|
65
|
+
item = details[0]
|
|
66
|
+
|
|
67
|
+
assert item["id"] == "A11Y.IMG.ALT"
|
|
68
|
+
assert item["help_hint"] is not None
|
|
69
|
+
assert "alt" in item["help_hint"]
|
|
70
|
+
assert item["help_url"] is not None
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
|
|
2
|
+
import pytest
|
|
3
|
+
import json
|
|
4
|
+
import jsonschema
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from click.testing import CliRunner
|
|
7
|
+
from a11y_ci.cli import main
|
|
8
|
+
from a11y_ci.scorecard import Scorecard
|
|
9
|
+
|
|
10
|
+
FIXTURES = Path(__file__).parent / "fixtures"
|
|
11
|
+
|
|
12
|
+
def test_load_scorecard_valid():
|
|
13
|
+
# Should not raise
|
|
14
|
+
s = Scorecard.load(str(FIXTURES / "current_ok.json"))
|
|
15
|
+
assert isinstance(s, Scorecard)
|
|
16
|
+
|
|
17
|
+
def test_load_scorecard_invalid_schema(tmp_path):
|
|
18
|
+
# Create invalid file
|
|
19
|
+
bad_file = tmp_path / "bad.json"
|
|
20
|
+
bad_file.write_text(json.dumps({"tool": "oops", "findings": []}), encoding="utf-8")
|
|
21
|
+
|
|
22
|
+
with pytest.raises(jsonschema.ValidationError):
|
|
23
|
+
Scorecard.load(str(bad_file))
|
|
24
|
+
|
|
25
|
+
def test_cli_schema_validation_error(tmp_path):
|
|
26
|
+
bad_file = tmp_path / "bad.json"
|
|
27
|
+
bad_file.write_text(json.dumps({"tool": "oops", "findings": []}), encoding="utf-8")
|
|
28
|
+
|
|
29
|
+
runner = CliRunner()
|
|
30
|
+
result = runner.invoke(main, ["gate", "--current", str(bad_file)])
|
|
31
|
+
|
|
32
|
+
# Should be exit code 2 (input error)
|
|
33
|
+
assert result.exit_code == 2
|
|
34
|
+
# Should contain our specific error message
|
|
35
|
+
assert "Scorecard format invalid" in result.output
|
|
36
|
+
assert "required property" in result.output or "is not of type" in result.output
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
|
|
2
|
+
import json
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from a11y_ci.scorecard import Scorecard, compute_fingerprint, normalize_severity
|
|
8
|
+
|
|
9
|
+
def test_fingerprint_stability():
|
|
10
|
+
"""Fingerprint should be stable for identical content."""
|
|
11
|
+
f1 = {"id": "A", "message": "msg", "location": {"path": "p"}}
|
|
12
|
+
f2 = {"id": "A", "message": "msg", "location": {"path": "p"}}
|
|
13
|
+
# Different ordering in dict shouldn't matter due to sort_keys=True
|
|
14
|
+
f3 = {"location": {"path": "p"}, "message": "msg", "id": "A"}
|
|
15
|
+
|
|
16
|
+
fp1 = compute_fingerprint(f1)
|
|
17
|
+
fp2 = compute_fingerprint(f2)
|
|
18
|
+
fp3 = compute_fingerprint(f3)
|
|
19
|
+
|
|
20
|
+
assert fp1 == fp2 == fp3
|
|
21
|
+
assert len(fp1) == 64 # SHA256 hex digest
|
|
22
|
+
|
|
23
|
+
def test_canonical_sort_order():
|
|
24
|
+
"""Findings should be sorted by Severity (desc), then ID, then Fingerprint."""
|
|
25
|
+
# S: serious, M: minor
|
|
26
|
+
findings = [
|
|
27
|
+
{"id": "B", "severity": "minor", "message": "msg1"},
|
|
28
|
+
{"id": "A", "severity": "serious", "message": "msg2"},
|
|
29
|
+
{"id": "C", "severity": "minor", "message": "msg3"},
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
sc = Scorecard(raw={}, findings=findings).canonicalize()
|
|
33
|
+
|
|
34
|
+
# Expected order:
|
|
35
|
+
# 1. A (serious)
|
|
36
|
+
# 2. B (minor)
|
|
37
|
+
# 3. C (minor)
|
|
38
|
+
|
|
39
|
+
assert sc.findings[0]["id"] == "A"
|
|
40
|
+
assert sc.findings[1]["id"] == "B"
|
|
41
|
+
assert sc.findings[2]["id"] == "C"
|
|
42
|
+
|
|
43
|
+
def test_deduplication():
|
|
44
|
+
"""Duplicate findings (same fingerprint) should collapse to one."""
|
|
45
|
+
f = {"id": "A", "severity": "serious", "message": "msg", "location": {"path": "p"}}
|
|
46
|
+
findings = [f, f.copy(), f.copy()] # 3 identical
|
|
47
|
+
|
|
48
|
+
sc = Scorecard(raw={}, findings=findings).canonicalize()
|
|
49
|
+
|
|
50
|
+
# Should be 1 finding
|
|
51
|
+
assert len(sc.findings) == 1
|
|
52
|
+
assert sc.findings[0]["id"] == "A"
|
|
53
|
+
|
|
54
|
+
def test_dedupe_preserves_highest_severity():
|
|
55
|
+
"""If fingerprints collide (forced), keep highest severity."""
|
|
56
|
+
# This scenario is unlikely with auto-fingerprinting unless content matches,
|
|
57
|
+
# but if manually provided or if collision happens:
|
|
58
|
+
|
|
59
|
+
# Manually force same fingerprint but different severity to test conflict resolution logic
|
|
60
|
+
fp = "same-hash"
|
|
61
|
+
f1 = {"id": "A", "severity": "minor", "fingerprint": fp}
|
|
62
|
+
f2 = {"id": "A", "severity": "critical", "fingerprint": fp} # Critical should win
|
|
63
|
+
|
|
64
|
+
findings = [f1, f2]
|
|
65
|
+
sc = Scorecard(raw={}, findings=findings).canonicalize()
|
|
66
|
+
|
|
67
|
+
assert len(sc.findings) == 1
|
|
68
|
+
assert sc.findings[0]["severity"] == "critical"
|
|
69
|
+
|
|
70
|
+
def test_load_canonicalizes_automatically(tmp_path):
|
|
71
|
+
"""Loading a scorecard should automatically canonicalize findings."""
|
|
72
|
+
data = {
|
|
73
|
+
"meta": {"tool": "test", "version": "1"},
|
|
74
|
+
"findings": [
|
|
75
|
+
{"id": "B", "severity": "minor", "message": "m"},
|
|
76
|
+
{"id": "A", "severity": "serious", "message": "m"},
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
p = tmp_path / "test.json"
|
|
80
|
+
p.write_text(json.dumps(data))
|
|
81
|
+
|
|
82
|
+
sc = Scorecard.load(str(p))
|
|
83
|
+
|
|
84
|
+
# Check sort order: A (serious) then B (minor)
|
|
85
|
+
assert sc.findings[0]["id"] == "A"
|
|
86
|
+
assert sc.findings[1]["id"] == "B"
|
|
87
|
+
# Check fingerprint added
|
|
88
|
+
assert "fingerprint" in sc.findings[0]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Smoke tests for a11y-ci CLI."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from click.testing import CliRunner
|
|
5
|
+
from a11y_ci.cli import main
|
|
6
|
+
|
|
7
|
+
FIXTURES = Path(__file__).parent / "fixtures"
|
|
8
|
+
|
|
9
|
+
def test_gate_pass_smoke():
|
|
10
|
+
"""Gate should pass with a clean scorecard (exit 0)."""
|
|
11
|
+
runner = CliRunner()
|
|
12
|
+
result = runner.invoke(main, [
|
|
13
|
+
"gate",
|
|
14
|
+
"--current", str(FIXTURES / "current_ok.json"),
|
|
15
|
+
"--fail-on", "serious"
|
|
16
|
+
])
|
|
17
|
+
assert result.exit_code == 0
|
|
18
|
+
assert "[OK]" in result.output
|
|
19
|
+
|
|
20
|
+
def test_gate_fail_smoke():
|
|
21
|
+
"""Gate should fail with findings above threshold (exit 3)."""
|
|
22
|
+
runner = CliRunner()
|
|
23
|
+
result = runner.invoke(main, [
|
|
24
|
+
"gate",
|
|
25
|
+
"--current", str(FIXTURES / "current_fail.json"),
|
|
26
|
+
"--fail-on", "serious"
|
|
27
|
+
])
|
|
28
|
+
assert result.exit_code == 3
|
|
29
|
+
assert "[ERROR]" in result.output
|
|
30
|
+
# Ensure failure reason is clear
|
|
31
|
+
assert "Current run has" in result.output
|
|
32
|
+
|
|
33
|
+
def test_gate_malformed_input():
|
|
34
|
+
"""Gate should fail gracefully on malformed/missing input (exit 2)."""
|
|
35
|
+
runner = CliRunner()
|
|
36
|
+
result = runner.invoke(main, [
|
|
37
|
+
"gate",
|
|
38
|
+
"--current", "non_existent_file.json"
|
|
39
|
+
])
|
|
40
|
+
assert result.exit_code == 2
|
|
41
|
+
assert "Invalid value for '--current'" in result.output
|
package/src/a11y-lint/README.md
CHANGED
|
@@ -59,6 +59,13 @@ def main() -> None:
|
|
|
59
59
|
help="Enable only specific rules (can be used multiple times).",
|
|
60
60
|
)
|
|
61
61
|
@click.option("--strict", is_flag=True, help="Treat warnings as errors.")
|
|
62
|
+
@click.option(
|
|
63
|
+
"--artifact-dir",
|
|
64
|
+
"artifact_dir",
|
|
65
|
+
type=click.Path(file_okay=False, writable=True),
|
|
66
|
+
required=False,
|
|
67
|
+
help="Directory to write unified artifacts (result.json, current.scorecard.json).",
|
|
68
|
+
)
|
|
62
69
|
def scan(
|
|
63
70
|
input: str | None,
|
|
64
71
|
stdin: bool,
|
|
@@ -68,6 +75,7 @@ def scan(
|
|
|
68
75
|
disable: tuple[str, ...],
|
|
69
76
|
enable: tuple[str, ...],
|
|
70
77
|
strict: bool,
|
|
78
|
+
artifact_dir: str | None,
|
|
71
79
|
) -> None:
|
|
72
80
|
"""Scan CLI text for accessibility issues.
|
|
73
81
|
|
|
@@ -137,6 +145,27 @@ def scan(
|
|
|
137
145
|
renderer.write_batch(messages)
|
|
138
146
|
renderer.write_summary()
|
|
139
147
|
|
|
148
|
+
# Artifact generation
|
|
149
|
+
if artifact_dir:
|
|
150
|
+
out_dir = Path(artifact_dir)
|
|
151
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
|
|
153
|
+
# 1. result.json (full findings)
|
|
154
|
+
result = {
|
|
155
|
+
"source": source,
|
|
156
|
+
"messages": [msg.to_dict() for msg in messages],
|
|
157
|
+
"summary": {
|
|
158
|
+
"total": len(messages),
|
|
159
|
+
"errors": scanner.error_count,
|
|
160
|
+
"warnings": scanner.warn_count,
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
(out_dir / "result.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
164
|
+
|
|
165
|
+
# 2. current.scorecard.json (for a11y-ci gate)
|
|
166
|
+
card = create_scorecard(messages, name=f"Scan: {Path(source).name}")
|
|
167
|
+
(out_dir / "current.scorecard.json").write_text(json.dumps(card.to_dict(), indent=2), encoding="utf-8")
|
|
168
|
+
|
|
140
169
|
# Exit with error if issues found
|
|
141
170
|
exit_code = 0
|
|
142
171
|
if scanner.error_count > 0:
|