Consume governed recipients and add operational checks
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# Campaign Accessibility Review
|
||||
|
||||
The Campaign WebUI uses Core's semantic `Button`, `Dialog`, `DataGrid`, form,
|
||||
alert, and segmented-control components. Core owns focus trapping, focus return,
|
||||
Escape handling, labels, disabled state, and keyboard behavior for those shared
|
||||
primitives.
|
||||
|
||||
## Repeatable review matrix
|
||||
|
||||
Run this matrix for Campaign overview, wizard, sender and recipients,
|
||||
attachments, template editing, review and send, operator queue, and reports:
|
||||
|
||||
1. Navigate all actions with Tab and Shift+Tab; focus must remain visible and
|
||||
follow the visual reading order.
|
||||
2. Activate buttons and links with Enter, and native buttons with Space.
|
||||
3. Open every dialog, verify initial focus remains within it, close with Escape,
|
||||
and verify focus returns to the opener.
|
||||
4. Use DataGrid sorting, filtering, pagination, row selection, and action menus
|
||||
without a pointer.
|
||||
5. At 200 percent browser zoom and a 320 CSS-pixel viewport, verify that content
|
||||
reflows or scrolls without hiding actions.
|
||||
6. With reduced motion enabled, verify that workflow state does not depend on
|
||||
animation.
|
||||
7. With a screen reader, verify page headings, field labels, validation errors,
|
||||
workflow states, message navigation, and attachment evidence.
|
||||
|
||||
## Automated structural guard
|
||||
|
||||
`npm run test:accessibility-contract` rejects non-semantic click handlers,
|
||||
unlabelled icon-only buttons in the message preview, and Campaign-local modal
|
||||
implementations that bypass Core's `Dialog`. It complements rather than replaces
|
||||
browser and assistive-technology testing.
|
||||
|
||||
## Known boundary
|
||||
|
||||
Translation keys may be visible in source because Core resolves them at runtime.
|
||||
The review must use a built application with current language packages. WCAG
|
||||
conformance is a release-level claim and still requires a bounded manual audit
|
||||
of the release candidate.
|
||||
@@ -135,9 +135,15 @@ just the authoring form:
|
||||
5. Confirm attachment behavior when a rule matches no files, ZIP/password
|
||||
behavior, and any recipient-specific files.
|
||||
6. Record review completion and the inspected message keys through the review
|
||||
surface. The current baseline does not persist a distinct approve/reject
|
||||
decision or review reason. If content, recipients, attachment inputs, owner
|
||||
context, or non-secret transport identity changes, revalidate and rebuild.
|
||||
surface. Validation, build, review, and exception evidence records the actor,
|
||||
timestamp, and immutable build token/message digest where applicable. If
|
||||
content, recipients, attachment inputs, owner context, or non-secret
|
||||
transport identity changes, revalidate and rebuild.
|
||||
|
||||
This evidence is the Campaign input to separation-of-duties policy. Generic
|
||||
approve/reject chains, delegation, substitutions, escalation, and signatures
|
||||
belong to the optional Approvals capability. Campaign must not claim an
|
||||
approval merely because validation, building, or message review completed.
|
||||
|
||||
Normal readers and reviewers see business state and safe evidence. Process-local
|
||||
paths, storage keys, worker claim tokens, and raw provider diagnostics require
|
||||
|
||||
@@ -27,6 +27,7 @@ from govoplan_core.core.modules import (
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.core.postbox import (
|
||||
@@ -254,7 +255,7 @@ manifest = ModuleManifest(
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="addresses.recipient_source",
|
||||
version_min="0.1.0",
|
||||
version_min="0.1.9",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
@@ -750,6 +751,17 @@ manifest = ModuleManifest(
|
||||
fromlist=["retention_capability"],
|
||||
).retention_capability(context),
|
||||
},
|
||||
operational_check_providers=(
|
||||
OperationalCheckProviderRegistration(
|
||||
module_id="campaigns",
|
||||
check_id="campaign.generated_eml_storage",
|
||||
provider=lambda: __import__(
|
||||
"govoplan_campaign.backend.operational_checks",
|
||||
fromlist=["generated_eml_storage_check"],
|
||||
).generated_eml_storage_check(),
|
||||
cache_seconds=60,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.operations import OperationalCheck
|
||||
from govoplan_campaign.backend.persistence.campaigns import BUILD_OUTPUT_DIR
|
||||
|
||||
|
||||
def generated_eml_storage_check() -> OperationalCheck:
|
||||
"""Verify the current EML evidence path and report its node-local boundary."""
|
||||
|
||||
root = Path(BUILD_OUTPUT_DIR)
|
||||
probe = root / ".govoplan-health" / f"{secrets.token_hex(16)}.probe"
|
||||
payload = secrets.token_bytes(64)
|
||||
try:
|
||||
probe.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||
with probe.open("xb") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if probe.read_bytes() != payload:
|
||||
raise OSError("generated EML persistence returned different bytes")
|
||||
except OSError as exc:
|
||||
return OperationalCheck(
|
||||
id="campaign.generated_eml_storage",
|
||||
label="Generated Campaign EML evidence",
|
||||
state="error",
|
||||
detail=(
|
||||
"The generated EML evidence path failed a bounded durable-write probe "
|
||||
f"({type(exc).__name__})."
|
||||
),
|
||||
readiness_critical=True,
|
||||
metrics={"backend": "node_local_filesystem"},
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
probe.unlink(missing_ok=True)
|
||||
probe.parent.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return OperationalCheck(
|
||||
id="campaign.generated_eml_storage",
|
||||
label="Generated Campaign EML evidence",
|
||||
state="warning",
|
||||
detail=(
|
||||
"Generated EML passed a local write/fsync/read probe, but remains node-local. "
|
||||
"Use one shared runtime volume and include it in coordinated backups until "
|
||||
"Campaign evidence is migrated to managed object storage."
|
||||
),
|
||||
metrics={"backend": "node_local_filesystem"},
|
||||
)
|
||||
|
||||
@@ -356,7 +356,15 @@ def validate_campaign_version(
|
||||
postbox_available=postbox_integration().available,
|
||||
)
|
||||
report_json = report.model_dump(mode="json")
|
||||
report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count})
|
||||
report_json.update(
|
||||
{
|
||||
"ok": report.ok,
|
||||
"error_count": report.error_count,
|
||||
"warning_count": report.warning_count,
|
||||
"validated_at": datetime.now(UTC).isoformat(),
|
||||
"validated_by_user_id": user_id,
|
||||
}
|
||||
)
|
||||
version.validation_summary = report_json
|
||||
|
||||
# Replace version-level semantic issues from previous validations.
|
||||
@@ -642,6 +650,7 @@ def build_campaign_version(
|
||||
tenant_id: str,
|
||||
version_id: str,
|
||||
write_eml: bool = True,
|
||||
user_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
version, snapshot_path, config = load_version_config(session, version_id)
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
@@ -687,6 +696,7 @@ def build_campaign_version(
|
||||
entries_by_index=entries_by_index,
|
||||
)
|
||||
report_json = _campaign_build_report(result, files)
|
||||
report_json["built_by_user_id"] = user_id
|
||||
version.build_summary = report_json
|
||||
editor_state = copy.deepcopy(version.editor_state or {})
|
||||
editor_state.pop("review_send", None)
|
||||
|
||||
@@ -19,6 +19,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
CampaignRecipientAddressSourcesResponse,
|
||||
CampaignRecipientAddressSourceSnapshotRequest,
|
||||
CampaignRecipientAddressSourceSnapshotResponse,
|
||||
CampaignRecipientSnapshotExcludedItem,
|
||||
CampaignRecipientSnapshotItem,
|
||||
RecipientImportMappingProfileListResponse,
|
||||
RecipientImportMappingProfilePayload,
|
||||
@@ -764,7 +765,11 @@ def snapshot_campaign_recipient_address_source(
|
||||
)
|
||||
try:
|
||||
snapshot = getattr(capability, "snapshot")(
|
||||
session, principal, source_id=payload.source_id
|
||||
session,
|
||||
principal,
|
||||
source_id=payload.source_id,
|
||||
purpose=payload.purpose,
|
||||
requested_channels=("email",),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
@@ -775,6 +780,15 @@ def snapshot_campaign_recipient_address_source(
|
||||
CampaignRecipientSnapshotItem.model_validate(_capability_payload(item))
|
||||
for item in snapshot_payload.get("recipients", [])
|
||||
]
|
||||
excluded = [
|
||||
CampaignRecipientSnapshotExcludedItem.model_validate(_capability_payload(item))
|
||||
for item in snapshot_payload.get("excluded", [])
|
||||
]
|
||||
provenance = (
|
||||
snapshot_payload.get("provenance")
|
||||
if isinstance(snapshot_payload.get("provenance"), dict)
|
||||
else {}
|
||||
)
|
||||
return CampaignRecipientAddressSourceSnapshotResponse(
|
||||
source_id=str(snapshot_payload.get("source_id") or ""),
|
||||
source_label=str(snapshot_payload.get("source_label") or ""),
|
||||
@@ -782,9 +796,16 @@ def snapshot_campaign_recipient_address_source(
|
||||
source_revision=str(snapshot_payload.get("source_revision") or ""),
|
||||
generated_at=str(snapshot_payload.get("generated_at") or ""),
|
||||
recipients=recipients,
|
||||
provenance=snapshot_payload.get("provenance")
|
||||
if isinstance(snapshot_payload.get("provenance"), dict)
|
||||
else {},
|
||||
excluded=excluded,
|
||||
included_count=len(recipients),
|
||||
excluded_count=len(excluded),
|
||||
purpose=str(snapshot_payload.get("purpose") or payload.purpose),
|
||||
provenance={
|
||||
**provenance,
|
||||
"campaign_id": campaign_id,
|
||||
"purpose": payload.purpose,
|
||||
"requested_channels": ["email"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -658,6 +658,7 @@ def build_version(
|
||||
tenant_id=principal.tenant_id,
|
||||
version_id=version_id,
|
||||
write_eml=payload.write_eml if payload else True,
|
||||
user_id=principal.user.id,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
|
||||
@@ -378,6 +378,7 @@ class CampaignRecipientAddressSourceSnapshotRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
source_id: str = Field(min_length=1)
|
||||
purpose: str = Field(default="campaign_delivery", min_length=1, max_length=120)
|
||||
|
||||
|
||||
class CampaignRecipientSnapshotItem(BaseModel):
|
||||
@@ -389,6 +390,18 @@ class CampaignRecipientSnapshotItem(BaseModel):
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CampaignRecipientSnapshotExcludedItem(BaseModel):
|
||||
contact_id: str
|
||||
display_name: str
|
||||
channel: str
|
||||
target: str
|
||||
contact_point_id: str | None = None
|
||||
status: str
|
||||
reason_code: str | None = None
|
||||
explanation: str | None = None
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
|
||||
source_id: str
|
||||
source_label: str
|
||||
@@ -396,6 +409,10 @@ class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
|
||||
source_revision: str
|
||||
generated_at: str
|
||||
recipients: list[CampaignRecipientSnapshotItem] = Field(default_factory=list)
|
||||
excluded: list[CampaignRecipientSnapshotExcludedItem] = Field(default_factory=list)
|
||||
included_count: int = 0
|
||||
excluded_count: int = 0
|
||||
purpose: str = "campaign_delivery"
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_campaign.backend.operational_checks import generated_eml_storage_check
|
||||
|
||||
|
||||
def test_generated_eml_probe_reports_node_local_boundary(tmp_path) -> None:
|
||||
with patch(
|
||||
"govoplan_campaign.backend.operational_checks.BUILD_OUTPUT_DIR",
|
||||
tmp_path,
|
||||
):
|
||||
result = generated_eml_storage_check()
|
||||
|
||||
assert result.state == "warning"
|
||||
assert "node-local" in result.detail
|
||||
assert list(tmp_path.rglob("*.probe")) == []
|
||||
|
||||
@@ -78,6 +78,58 @@ class CampaignPartialValidationTests(unittest.TestCase):
|
||||
self.assertTrue(response.available)
|
||||
self.assertEqual(response.candidates[0].email, "ada@example.local")
|
||||
|
||||
def test_address_source_snapshot_preserves_governance_exclusions(self) -> None:
|
||||
class RecipientSourceCapability:
|
||||
def snapshot(self, _session, _principal, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
return {
|
||||
"source_id": "addresses:address_book:book-1",
|
||||
"source_label": "Residents",
|
||||
"source_kind": "local",
|
||||
"source_revision": "revision-2",
|
||||
"generated_at": "2026-07-31T10:00:00+00:00",
|
||||
"purpose": "campaign_delivery",
|
||||
"recipients": [
|
||||
{
|
||||
"contact_id": "contact-1",
|
||||
"display_name": "Ada Lovelace",
|
||||
"email": "ada@example.local",
|
||||
"fields": {},
|
||||
"provenance": {"channel_decision": {"governance_state": "opted_in"}},
|
||||
}
|
||||
],
|
||||
"excluded": [
|
||||
{
|
||||
"contact_id": "contact-2",
|
||||
"display_name": "Grace Hopper",
|
||||
"channel": "email",
|
||||
"target": "grace@example.local",
|
||||
"status": "suppressed",
|
||||
"reason_code": "addresses.channel.opted_out",
|
||||
"provenance": {"channel_decision": {"governance_state": "opted_out"}},
|
||||
}
|
||||
],
|
||||
"provenance": {"governance_applied": True},
|
||||
}
|
||||
|
||||
capability = RecipientSourceCapability()
|
||||
payload = router.CampaignRecipientAddressSourceSnapshotRequest(
|
||||
source_id="addresses:address_book:book-1"
|
||||
)
|
||||
with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object(
|
||||
router, "_registry_capability", return_value=capability
|
||||
):
|
||||
response = router.snapshot_campaign_recipient_address_source(
|
||||
"campaign-1", payload, session=object(), principal=object()
|
||||
)
|
||||
|
||||
self.assertEqual(capability.kwargs["purpose"], "campaign_delivery")
|
||||
self.assertEqual(capability.kwargs["requested_channels"], ("email",))
|
||||
self.assertEqual(response.included_count, 1)
|
||||
self.assertEqual(response.excluded_count, 1)
|
||||
self.assertEqual(response.excluded[0].reason_code, "addresses.channel.opted_out")
|
||||
self.assertTrue(response.provenance["governance_applied"])
|
||||
|
||||
|
||||
class CampaignSemanticValidationTests(unittest.TestCase):
|
||||
def test_send_mode_requires_campaign_owned_sender_for_each_inline_entry(self) -> None:
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@
|
||||
"test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs",
|
||||
"test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs",
|
||||
"test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs",
|
||||
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs"
|
||||
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
|
||||
"test:accessibility-contract": "node tests/accessibility-contract.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -256,6 +256,18 @@ export type CampaignRecipientSnapshotItem = {
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignRecipientSnapshotExcludedItem = {
|
||||
contact_id: string;
|
||||
display_name: string;
|
||||
channel: string;
|
||||
target: string;
|
||||
contact_point_id?: string | null;
|
||||
status: string;
|
||||
reason_code?: string | null;
|
||||
explanation?: string | null;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignRecipientAddressSourceSnapshot = {
|
||||
source_id: string;
|
||||
source_label: string;
|
||||
@@ -263,6 +275,10 @@ export type CampaignRecipientAddressSourceSnapshot = {
|
||||
source_revision: string;
|
||||
generated_at: string;
|
||||
recipients: CampaignRecipientSnapshotItem[];
|
||||
excluded: CampaignRecipientSnapshotExcludedItem[];
|
||||
included_count: number;
|
||||
excluded_count: number;
|
||||
purpose: string;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -714,7 +730,7 @@ sourceId: string)
|
||||
: Promise<CampaignRecipientAddressSourceSnapshot> {
|
||||
return apiFetch<CampaignRecipientAddressSourceSnapshot>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources/snapshot`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source_id: sourceId })
|
||||
body: JSON.stringify({ source_id: sourceId, purpose: "campaign_delivery" })
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,14 @@ export default function AddressSourceImportDialog({
|
||||
() => sources.find((source) => source.source_id === selectedSourceId) ?? null,
|
||||
[selectedSourceId, sources]
|
||||
);
|
||||
const exclusionCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const item of snapshot?.excluded ?? []) {
|
||||
const reason = item.reason_code || item.status || "excluded";
|
||||
counts.set(reason, (counts.get(reason) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()].sort((left, right) => right[1] - left[1]);
|
||||
}, [snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredSources.some((source) => source.source_id === selectedSourceId)) {
|
||||
@@ -276,14 +284,26 @@ export default function AddressSourceImportDialog({
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Recipients</dt>
|
||||
<dd>{snapshot.recipients.length}</dd>
|
||||
<dt>Included</dt>
|
||||
<dd>{snapshot.included_count}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Excluded</dt>
|
||||
<dd>{snapshot.excluded_count}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Revision</dt>
|
||||
<dd className="mono-small">{snapshot.source_revision}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{snapshot.excluded_count > 0 && (
|
||||
<DismissibleAlert tone="warning" compact dismissible={false}>
|
||||
{snapshot.excluded_count} contact point{snapshot.excluded_count === 1 ? " was" : "s were"} excluded by effective communication-governance facts.
|
||||
{exclusionCounts.length > 0 && (
|
||||
<span> {exclusionCounts.map(([reason, count]) => `${reason}: ${count}`).join(" · ")}</span>
|
||||
)}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
<AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
|
||||
{snapshot.recipients.length > 20 && (
|
||||
<p className="muted small-note">
|
||||
|
||||
@@ -98,7 +98,27 @@ export function createAddressSourceImportProvenance(
|
||||
source_id: snapshot.source_id,
|
||||
source_label: snapshot.source_label,
|
||||
source_revision: snapshot.source_revision,
|
||||
source_provenance: snapshot.provenance,
|
||||
source_provenance: {
|
||||
...snapshot.provenance,
|
||||
generated_at: snapshot.generated_at,
|
||||
purpose: snapshot.purpose,
|
||||
included_count: snapshot.included_count,
|
||||
excluded_count: snapshot.excluded_count,
|
||||
included_decisions: snapshot.recipients.map((recipient) => ({
|
||||
contact_id: recipient.contact_id,
|
||||
email: recipient.email,
|
||||
provenance: recipient.provenance
|
||||
})),
|
||||
exclusions: snapshot.excluded.map((item) => ({
|
||||
contact_id: item.contact_id,
|
||||
channel: item.channel,
|
||||
target: item.target,
|
||||
status: item.status,
|
||||
reason_code: item.reason_code,
|
||||
explanation: item.explanation,
|
||||
provenance: item.provenance
|
||||
}))
|
||||
},
|
||||
filename: null,
|
||||
sheet_name: null,
|
||||
encoding: null,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const sourceRoot = path.resolve("src");
|
||||
|
||||
function sourceFiles(directory) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) return sourceFiles(target);
|
||||
return /\.(tsx|ts)$/.test(entry.name) ? [target] : [];
|
||||
});
|
||||
}
|
||||
|
||||
const sources = sourceFiles(sourceRoot).map((file) => ({
|
||||
file,
|
||||
value: fs.readFileSync(file, "utf8"),
|
||||
}));
|
||||
|
||||
const nonSemanticClicks = sources.flatMap(({ file, value }) =>
|
||||
[...value.matchAll(/<(div|span|li|tr)\b[^>]*\bonClick\s*=/g)].map(
|
||||
(match) => `${path.relative(sourceRoot, file)}:${match.index}`,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
nonSemanticClicks,
|
||||
[],
|
||||
`Use a semantic button/link instead of clickable layout elements: ${nonSemanticClicks.join(", ")}`,
|
||||
);
|
||||
|
||||
const preview = fs.readFileSync(
|
||||
path.join(sourceRoot, "features/campaigns/components/MessagePreviewOverlay.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
for (const handler of ["onFirst", "onPrevious", "onNext", "onLast"]) {
|
||||
const buttonLine = preview
|
||||
.split("\n")
|
||||
.find((line) => line.includes(`<button`) && line.includes(`navigation.${handler}`));
|
||||
assert.ok(
|
||||
buttonLine?.includes("aria-label="),
|
||||
`${handler} must remain an explicitly labelled button`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const { file, value } of sources) {
|
||||
if (!value.includes('role="dialog"')) continue;
|
||||
assert.fail(
|
||||
`${path.relative(sourceRoot, file)} implements a local dialog; use Core's Dialog component`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Campaign accessibility structural contract passed.");
|
||||
Reference in New Issue
Block a user