@event4u/agent-config 15.0.0 → 16.1.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +301 -347
- package/CONTRIBUTING.md +9 -0
- package/README.md +2 -2
- package/dist/agent-src/commands/analyze/inbox.md +26 -0
- package/dist/agent-src/commands/analyze/roadmap-repos.md +27 -0
- package/dist/agent-src/commands/challenge-me/closure.md +178 -0
- package/dist/agent-src/commands/challenge-me.md +9 -2
- package/dist/agent-src/commands/council/default.md +25 -5
- package/dist/agent-src/commands/feature/plan.md +28 -1
- package/dist/agent-src/commands/feature/roadmap.md +28 -1
- package/dist/agent-src/commands/implement-ticket.md +27 -0
- package/dist/agent-src/commands/jira-ticket.md +27 -0
- package/dist/agent-src/commands/pr/create.md +29 -0
- package/dist/agent-src/commands/pr/merge.md +60 -9
- package/dist/agent-src/commands/roadmap/ai-council.md +7 -1
- package/dist/agent-src/commands/roadmap/create.md +77 -29
- package/dist/agent-src/commands/roadmap/materialize.md +27 -0
- package/dist/agent-src/commands/roadmap/process-full.md +123 -18
- package/dist/agent-src/commands/team.md +22 -0
- package/dist/agent-src/contexts/authority/scope-growth-ownership.md +80 -0
- package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +97 -0
- package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +44 -3
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +15 -15
- package/dist/agent-src/contexts/execution/decision-closure-execution.md +93 -0
- package/dist/agent-src/contexts/execution/fix-loop-ladder.md +108 -0
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +4 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +75 -6
- package/dist/agent-src/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
- package/dist/agent-src/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
- package/dist/agent-src/guidelines/design-fidelity-routing.md +29 -0
- package/dist/agent-src/rules/autonomous-execution.md +15 -8
- package/dist/agent-src/rules/delegation-policy.md +23 -45
- package/dist/agent-src/rules/design-fidelity.md +32 -37
- package/dist/agent-src/rules/evaluator-independence.md +43 -49
- package/dist/agent-src/rules/test-first.md +41 -0
- package/dist/agent-src/rules/user-interaction.md +31 -30
- package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
- package/dist/agent-src/skills/ai-council/SKILL.md +11 -3
- package/dist/agent-src/skills/api-design/data/manifest.json +3 -3
- package/dist/agent-src/skills/database/data/manifest.json +4 -4
- package/dist/agent-src/skills/database/data/query-tuning.csv +3 -3
- package/dist/agent-src/skills/design-review/SKILL.md +50 -0
- package/dist/agent-src/skills/error-handling-patterns/SKILL.md +9 -0
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +14 -4
- package/dist/agent-src/skills/experiment-loop/SKILL.md +31 -1
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/storybook-workshop/SKILL.md +42 -3
- package/dist/agent-src/skills/threat-modeling/SKILL.md +9 -4
- package/dist/agent-src/skills/threat-modeling/data/manifest.json +4 -4
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +37 -3
- package/dist/agent-src/templates/agent-settings.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +188 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
- package/dist/cli-delegate/check_update_banner.js +40 -2
- package/dist/cli-delegate/chunk-4E2W63DA.js +335 -0
- package/dist/cli-delegate/chunk-5R7HOLAF.js +99 -0
- package/dist/cli-delegate/{chunk-IVBPRCQO.js → chunk-6KFEC3G7.js} +1 -1
- package/dist/cli-delegate/{chunk-2TAQ2K4V.js → chunk-6KM6ORRY.js} +55 -386
- package/dist/cli-delegate/{chunk-2XY7ILBT.js → chunk-FC75PGJE.js} +42 -2
- package/dist/cli-delegate/{chunk-GYC3VRYN.js → chunk-FXYUYU4N.js} +57 -13
- package/dist/cli-delegate/{chunk-55ACLDUE.js → chunk-GJCBB2UG.js} +168 -10
- package/dist/cli-delegate/{chunk-AK6HODNQ.js → chunk-HDVVGPQW.js} +873 -539
- package/dist/cli-delegate/chunk-NUHLI2RX.js +80 -0
- package/dist/cli-delegate/{chunk-HZ52BRGG.js → chunk-P6ZHP5W2.js} +15 -90
- package/dist/cli-delegate/{chunk-TYSY33MD.js → chunk-QJWAUMJW.js} +12 -2
- package/dist/cli-delegate/{chunk-57PN4GYJ.js → chunk-RGNIWTEA.js} +1 -1
- package/dist/cli-delegate/chunk-ROYYTEZU.js +39 -0
- package/dist/cli-delegate/{chunk-2KKXJW25.js → chunk-S3VFXMTM.js} +1 -1
- package/dist/cli-delegate/{chunk-3FKR64R5.js → chunk-UNCRJBT3.js} +31 -7
- package/dist/cli-delegate/{chunk-2DK2VJWG.js → chunk-WGXTVKCQ.js} +20 -6
- package/dist/cli-delegate/chunk-YDPILCOL.js +152 -0
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +918 -325
- package/dist/cli-delegate/cmd_doctor.js +7 -5
- package/dist/cli-delegate/cmd_explain.js +5 -3
- package/dist/cli-delegate/cmd_export.js +9 -7
- package/dist/cli-delegate/cmd_fleet.js +4 -2
- package/dist/cli-delegate/cmd_handoff.js +6 -6
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +3 -2
- package/dist/cli-delegate/cmd_preflight.js +4 -2
- package/dist/cli-delegate/cmd_prune.js +10 -8
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +4 -4
- package/dist/cli-delegate/cmd_settings_get.js +28 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +9 -7
- package/dist/cli-delegate/cmd_settings_set.js +14 -1
- package/dist/cli-delegate/cmd_sync.js +10 -8
- package/dist/cli-delegate/cmd_uninstall.js +16 -13
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +12 -10
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +87 -40
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +10 -8
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +8 -6
- package/dist/hooks/dispatch.js +2601 -1073
- package/dist/install/claudeRuleRewrite.js +14 -3
- package/dist/install/conflict.js +60 -10
- package/dist/install/conflictTracker.js +218 -0
- package/dist/install/fsPrimitives.js +54 -0
- package/dist/install/install.mjs +2008 -1481
- package/dist/install/manifestFiles.js +259 -0
- package/dist/install/preflight.js +14 -2
- package/dist/install/preserve.js +157 -0
- package/dist/install/recordedOwnership.js +132 -0
- package/dist/install/txlog.js +11 -2
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1612 -667
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +3 -2
- package/dist/scripts/_lib/fs_atomic.js +167 -0
- package/dist/scripts/_lib/installed_lock.js +1 -1
- package/dist/scripts/_lib/installed_tools.js +460 -0
- package/dist/scripts/_lib/lean_projection_mode.js +3 -2
- package/dist/scripts/_lib/settings_renamed_keys.js +78 -0
- package/dist/server/io/yamlIO.js +50 -0
- package/dist/server/routes/install.js +8 -0
- package/dist/server/routes/wizard.js +9 -3
- package/dist/server/schemas/settings.js +17 -5
- package/dist/shared/missionExecution.js +97 -0
- package/dist/ui/assets/{index-D0jaCRPM.js → index-DH1xc62v.js} +17 -17
- package/dist/ui/index.html +1 -1
- package/docs/MIGRATION.md +103 -0
- package/docs/contracts/settings-classes.md +26 -7
- package/docs/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
- package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
- package/docs/guidelines/design-fidelity-routing.md +29 -0
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
- package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
- package/src/config/agent-settings.template.yml +139 -27
- package/src/config/auto-rule-budget.json +16 -0
- package/src/config/ci-local-parity.yml +18 -5
- package/src/config/continuity-surface.json +28 -0
- package/src/config/gate-coverage.yml +146 -13
- package/src/config/gate-violation-baselines.json +6 -6
- package/src/config/hook-token-budget.json +2 -0
- package/src/config/host-injection-effect.json +1 -1
- package/src/config/loop-surfaces.yaml +215 -0
- package/src/config/platform-anchor.json +3 -2
- package/src/config/preamble-payload-budget.json +12 -7
- package/src/config/preamble-payload-exceptions.json +28 -0
- package/src/config/release-gate-locality.yml +11 -0
- package/src/config/routing-coverage-seed.json +18 -4
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/install/claudeRuleRewrite.ts +16 -2
- package/src/install/conflict.ts +65 -9
- package/src/install/conflictTracker.ts +299 -0
- package/src/install/fsPrimitives.ts +54 -0
- package/src/install/manifestFiles.ts +312 -0
- package/src/install/preflight.ts +16 -2
- package/src/install/preserve.ts +205 -0
- package/src/install/recordedOwnership.ts +149 -0
- package/src/install/txlog.ts +11 -2
- package/src/install/types.ts +19 -4
- package/src/scripts/_cli/cmd_conformance.ts +88 -15
- package/src/scripts/_cli/cmd_doctor.ts +4 -3
- package/src/scripts/_cli/cmd_route_explain.ts +9 -2
- package/src/scripts/_cli/cmd_settings_get.ts +44 -0
- package/src/scripts/_cli/doctor_execution.ts +90 -0
- package/src/scripts/_dispatch.bash +3 -1
- package/src/scripts/_lib/activation_payload.ts +247 -0
- package/src/scripts/_lib/agent_settings.ts +4 -2
- package/src/scripts/_lib/authority_path.ts +102 -0
- package/src/scripts/_lib/base_ref_payload.ts +315 -0
- package/src/scripts/_lib/cascade_base.ts +94 -0
- package/src/scripts/_lib/changelog_eras.ts +7 -2
- package/src/scripts/_lib/continuation_ladder.ts +149 -2
- package/src/scripts/_lib/council_transport.ts +129 -0
- package/src/scripts/_lib/delivery_ready.ts +112 -0
- package/src/scripts/_lib/dropped_decision.ts +142 -0
- package/src/scripts/_lib/forge_protection.ts +267 -0
- package/src/scripts/_lib/hook_platform_keys.ts +27 -0
- package/src/scripts/_lib/installed_lock.ts +1 -1
- package/src/scripts/_lib/json_python_parity.ts +127 -0
- package/src/scripts/_lib/lean_projection_mode.ts +14 -3
- package/src/scripts/_lib/loop_surfaces.ts +382 -0
- package/src/scripts/_lib/measured_payload_ceiling.ts +424 -0
- package/src/scripts/_lib/mission_record.ts +190 -0
- package/src/scripts/_lib/obligation_frequency.ts +217 -0
- package/src/scripts/_lib/obligations.ts +402 -0
- package/src/scripts/_lib/orchestration_record.ts +11 -20
- package/src/scripts/_lib/payload_catalogue_completeness.ts +211 -0
- package/src/scripts/_lib/release_findings_ingest.ts +324 -0
- package/src/scripts/_lib/release_highlights.ts +199 -10
- package/src/scripts/_lib/release_holds.ts +493 -0
- package/src/scripts/_lib/release_migration_gate.ts +184 -0
- package/src/scripts/_lib/release_tag_dispatch.ts +63 -0
- package/src/scripts/_lib/review_baseline.ts +254 -0
- package/src/scripts/_lib/review_skipped_record.ts +61 -1
- package/src/scripts/_lib/rides_along.ts +102 -0
- package/src/scripts/_lib/settings_renamed_keys.ts +82 -0
- package/src/scripts/_lib/standing_bound_ratchet.ts +216 -103
- package/src/scripts/_lib/test_delta.ts +166 -0
- package/src/scripts/_lib/test_provenance.ts +171 -0
- package/src/scripts/_lib/turn_end_refusals.ts +51 -4
- package/src/scripts/_lib/typed_op_grant.ts +246 -0
- package/src/scripts/_lib/typed_op_watch.ts +185 -0
- package/src/scripts/ai_council/config.ts +8 -11
- package/src/scripts/ai_council/fallback_config.ts +5 -3
- package/src/scripts/ai_council/ownership_classes.ts +118 -0
- package/src/scripts/ask_block_census.ts +285 -15
- package/src/scripts/check_beta_review_markers.ts +35 -4
- package/src/scripts/check_enforcement_matrix.ts +624 -0
- package/src/scripts/check_finding_dispositions.ts +167 -16
- package/src/scripts/check_gate_reachability.ts +173 -10
- package/src/scripts/check_generator_sync.ts +954 -0
- package/src/scripts/check_held_object_arrivals.ts +381 -0
- package/src/scripts/check_preamble_payload_budget.ts +474 -124
- package/src/scripts/check_release_highlights.ts +33 -1
- package/src/scripts/check_release_holds.ts +567 -0
- package/src/scripts/check_release_published.ts +1 -1
- package/src/scripts/check_reply_consistency.ts +86 -17
- package/src/scripts/check_rule_projection_integrity.ts +22 -12
- package/src/scripts/check_test_delta.ts +294 -0
- package/src/scripts/check_test_weakening.ts +299 -0
- package/src/scripts/closure_scan.ts +333 -0
- package/src/scripts/compile_hook_manifest.ts +43 -3
- package/src/scripts/council_record_shape.ts +209 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +127 -12
- package/src/scripts/hooks/block_kernel_rule_writes.ts +12 -2
- package/src/scripts/hooks/concern_registry.ts +6 -0
- package/src/scripts/hooks/design_pass_hook.ts +128 -3
- package/src/scripts/hooks/end_review_nudge_hook.ts +101 -21
- package/src/scripts/hooks/evidence_independence.ts +96 -0
- package/src/scripts/hooks/memory_recall_hook.ts +540 -0
- package/src/scripts/hooks/obligation_settle_hook.ts +182 -0
- package/src/scripts/hooks/review_baseline_hook.ts +130 -0
- package/src/scripts/hooks/rule_inject_hook.ts +49 -1
- package/src/scripts/hooks/run_continuation_hook.ts +17 -20
- package/src/scripts/hooks/turn_end_gate_hook.ts +232 -14
- package/src/scripts/hooks_doctor.ts +89 -1
- package/src/scripts/hooks_status.ts +19 -0
- package/src/scripts/install.ts +107 -207
- package/src/scripts/interruption_report.ts +8 -2
- package/src/scripts/janitor.ts +15 -1
- package/src/scripts/lint_decision_classes.ts +464 -0
- package/src/scripts/lint_hook_manifest.ts +11 -0
- package/src/scripts/lint_major_migration_sections.ts +450 -0
- package/src/scripts/lint_plan_risk_register.ts +47 -3
- package/src/scripts/lint_roadmap_blockers.ts +61 -0
- package/src/scripts/lint_roadmap_producers.ts +202 -0
- package/src/scripts/measure_rule_budget.ts +101 -5
- package/src/scripts/measure_turn_end_gate.ts +302 -5
- package/src/scripts/new_roadmap.ts +37 -0
- package/src/scripts/orchestration_record.ts +0 -5
- package/src/scripts/release.ts +66 -162
- package/src/scripts/release_drill.ts +460 -15
- package/src/scripts/release_env.ts +1 -1
- package/src/scripts/release_publication.ts +52 -1
- package/src/scripts/report_enforcement_drift.ts +566 -0
- package/src/scripts/report_held_object_arrivals.ts +456 -0
- package/src/scripts/report_host_injection_effect.ts +3 -11
- package/src/scripts/report_standing_payload_by_host.ts +26 -152
- package/src/scripts/schemas/agent-settings.schema.json +23 -0
- package/src/scripts/schemas/command.schema.json +4 -0
- package/src/scripts/scope_growth.ts +219 -0
- package/src/scripts/sync_agent_settings.ts +140 -18
- package/src/scripts/trigger_eval_grandfather.json +0 -1
- package/src/scripts/ui_conformance_probe.ts +622 -0
- package/src/server/io/yamlIO.ts +48 -0
- package/src/server/schemas/settings.ts +31 -7
- package/src/shared/missionExecution.ts +126 -0
- package/dist/cli-delegate/chunk-523HCFYO.js +0 -90
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* `ui_conformance_probe` — behavioural evidence for a built UI surface.
|
|
4
|
+
*
|
|
5
|
+
* Phase 2 of `road-to-behaviour-evidence-over-pixels`. A Playwright consumer
|
|
6
|
+
* that compares a target against a reference on the five dimensions a resting
|
|
7
|
+
* frame cannot contain, and writes `ui-conformance.json`.
|
|
8
|
+
*
|
|
9
|
+
* NOT A GATE. It has no `lint_` / `check_` / `audit_` prefix, emits no
|
|
10
|
+
* `scanned:` line and carries no `src/config/gate-coverage.yml` row, following
|
|
11
|
+
* the `annotate_r1_outcomes.ts` precedent. It is mounted in shadow only: the
|
|
12
|
+
* design-pass hook reads its artefact and reports a verdict that never blocks.
|
|
13
|
+
* Making it a gate is a separate decision with its own six surfaces.
|
|
14
|
+
*
|
|
15
|
+
* THE STRUCTURE GATE IS PER NODE, AND THAT IS THE WHOLE DESIGN.
|
|
16
|
+
* Nodes are keyed by `data-probe-id`. A reference node with no match in the
|
|
17
|
+
* target produces a `structure` finding and is then simply not style-compared;
|
|
18
|
+
* matched nodes are compared normally. The rejected alternative was a global
|
|
19
|
+
* halt — stop the entire run on the first unmatched node. It reads stricter and
|
|
20
|
+
* is worse: a target missing one element would report one finding and hide
|
|
21
|
+
* every behavioural defect behind it, which is precisely the "looked clean
|
|
22
|
+
* because it stopped early" failure. What the gate actually prevents is
|
|
23
|
+
* comparing an unmatched node against whatever sits in its position, because
|
|
24
|
+
* those style findings would be about the wrong element. Per-node matching
|
|
25
|
+
* prevents that by construction, without hiding anything.
|
|
26
|
+
*
|
|
27
|
+
* NO AGGREGATE, ANYWHERE. Counters per dimension, never a single number
|
|
28
|
+
* standing for conformance. A dimension that could not run reports
|
|
29
|
+
* `not_applicable` with a reason and carries `findings: null` — never 0, which
|
|
30
|
+
* would read as "ran and found nothing".
|
|
31
|
+
*
|
|
32
|
+
* THE NOISE CONTROL. Computed-style comparison over every property fires on
|
|
33
|
+
* font fallbacks and token indirection nobody cares about, so the compared set
|
|
34
|
+
* is curated (`COMPARED_PROPERTIES`) to the properties a design token actually
|
|
35
|
+
* lands in. A deviation that is intended is recorded in a sibling
|
|
36
|
+
* `conformance.declared.json` and suppressed with its reason retained, so an
|
|
37
|
+
* approved change is not re-litigated on every run.
|
|
38
|
+
*
|
|
39
|
+
* Exit codes: 0 written (with or without findings) · 1 usage error.
|
|
40
|
+
*/
|
|
41
|
+
import fs from 'node:fs';
|
|
42
|
+
import { createRequire } from 'node:module';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
45
|
+
|
|
46
|
+
// Type-only, and that is load-bearing rather than stylistic: these are erased
|
|
47
|
+
// at compile time, so they add no second runtime path to a module that refuses
|
|
48
|
+
// to be loaded twice (see `loadPlaywright`).
|
|
49
|
+
import type * as PlaywrightTest from '@playwright/test';
|
|
50
|
+
type Page = PlaywrightTest.Page;
|
|
51
|
+
|
|
52
|
+
export const DIMENSIONS = [
|
|
53
|
+
'structure',
|
|
54
|
+
'computed_style',
|
|
55
|
+
'interaction',
|
|
56
|
+
'viewport_matrix',
|
|
57
|
+
'media_emulation',
|
|
58
|
+
] as const;
|
|
59
|
+
export type Dimension = (typeof DIMENSIONS)[number];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Curated, not exhaustive. Every entry is a property a design token resolves
|
|
63
|
+
* into or a layout property a breakpoint changes; nothing here fires on a font
|
|
64
|
+
* fallback or on inherited text metrics.
|
|
65
|
+
*/
|
|
66
|
+
export const COMPARED_PROPERTIES = [
|
|
67
|
+
'background-color',
|
|
68
|
+
'color',
|
|
69
|
+
'font-size',
|
|
70
|
+
'line-height',
|
|
71
|
+
'padding-top',
|
|
72
|
+
'padding-left',
|
|
73
|
+
'display',
|
|
74
|
+
'flex-direction',
|
|
75
|
+
'align-items',
|
|
76
|
+
'gap',
|
|
77
|
+
'outline-color',
|
|
78
|
+
'outline-width',
|
|
79
|
+
'transition-duration',
|
|
80
|
+
] as const;
|
|
81
|
+
|
|
82
|
+
/** Widest first. A divergence is attributed to the first width it appears at. */
|
|
83
|
+
export const VIEWPORTS = [1440, 768, 375, 320] as const;
|
|
84
|
+
|
|
85
|
+
export const INTERACTION_STATES = ['hover', 'focus', 'active', 'keyboard'] as const;
|
|
86
|
+
|
|
87
|
+
export type StyleMap = Record<string, string>;
|
|
88
|
+
|
|
89
|
+
export interface NodeObservation {
|
|
90
|
+
probe_id: string;
|
|
91
|
+
base: StyleMap;
|
|
92
|
+
hover: StyleMap;
|
|
93
|
+
focus: StyleMap;
|
|
94
|
+
active: StyleMap;
|
|
95
|
+
keyboard: StyleMap;
|
|
96
|
+
reduced_motion: StyleMap;
|
|
97
|
+
viewports: Record<string, StyleMap>;
|
|
98
|
+
/**
|
|
99
|
+
* A serialization of what a click did to the document, or null where the
|
|
100
|
+
* node is not clickable. Comparing the EFFECT rather than the presence of a
|
|
101
|
+
* listener is the point: a registered handler that mutates nothing and a
|
|
102
|
+
* missing handler are the same defect to a user.
|
|
103
|
+
*/
|
|
104
|
+
click_effect: string | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface Observation {
|
|
108
|
+
variant: string;
|
|
109
|
+
source: string;
|
|
110
|
+
nodes: NodeObservation[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface Declaration {
|
|
114
|
+
probe_id: string;
|
|
115
|
+
dimension: Dimension;
|
|
116
|
+
property: string;
|
|
117
|
+
reason: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface Finding {
|
|
121
|
+
dimension: Dimension;
|
|
122
|
+
probe_id: string;
|
|
123
|
+
/** The interaction state, the viewport width, or null for a flat compare. */
|
|
124
|
+
state: string | null;
|
|
125
|
+
property: string | null;
|
|
126
|
+
expected: string;
|
|
127
|
+
observed: string;
|
|
128
|
+
detail: string;
|
|
129
|
+
/** Viewport dimension only: every width at which the divergence holds. */
|
|
130
|
+
widths?: string[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface DimensionRow {
|
|
134
|
+
dimension: Dimension;
|
|
135
|
+
status: 'exercised' | 'not_applicable';
|
|
136
|
+
/** `null` on a not-applicable row. Never 0 — see the header. */
|
|
137
|
+
findings: number | null;
|
|
138
|
+
reason?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface SuppressedDeviation {
|
|
142
|
+
probe_id: string;
|
|
143
|
+
property: string;
|
|
144
|
+
reason: string;
|
|
145
|
+
expected: string;
|
|
146
|
+
observed: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface ProbeArtefact {
|
|
150
|
+
schema: 'ui-conformance/v1';
|
|
151
|
+
generated_at: string;
|
|
152
|
+
host_class: 'A' | 'B' | 'C' | 'unknown';
|
|
153
|
+
target: string;
|
|
154
|
+
reference: string | null;
|
|
155
|
+
structure_gate: 'passed' | 'stopped';
|
|
156
|
+
unmatched_nodes: string[];
|
|
157
|
+
dimensions: DimensionRow[];
|
|
158
|
+
findings: Finding[];
|
|
159
|
+
declared_suppressed: SuppressedDeviation[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------- pure core
|
|
163
|
+
|
|
164
|
+
export function readObservation(file: string): Observation {
|
|
165
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8')) as Observation;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function loadDeclarations(file: string): Declaration[] {
|
|
169
|
+
try {
|
|
170
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf-8')) as { declared?: Declaration[] };
|
|
171
|
+
return raw.declared ?? [];
|
|
172
|
+
} catch {
|
|
173
|
+
// A missing declaration file means nothing is declared, which is the
|
|
174
|
+
// strict reading. It is never an error: most targets declare nothing.
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const isDeclared = (d: readonly Declaration[], probeId: string, dimension: Dimension, property: string): Declaration | undefined =>
|
|
180
|
+
d.find((x) => x.probe_id === probeId && x.dimension === dimension && x.property === property);
|
|
181
|
+
|
|
182
|
+
/** Did driving this state change anything, relative to the given baseline? */
|
|
183
|
+
function stateDelta(base: StyleMap, state: StyleMap): string[] {
|
|
184
|
+
return COMPARED_PROPERTIES.filter((p) => base[p] !== state[p]);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Which reading each state is measured AGAINST, and `active` is the interesting
|
|
189
|
+
* one. A pointer cannot press an element without hovering it first, so the
|
|
190
|
+
* `:active` reading always carries the `:hover` styles too. Measured against
|
|
191
|
+
* `base`, removing a single hover rule therefore surfaces as TWO findings —
|
|
192
|
+
* hover lost and active lost — which counts one defect twice and was exactly
|
|
193
|
+
* what the fixture caught on the first run. Measuring `active` against `hover`
|
|
194
|
+
* isolates what pressing actually contributes, and a design that darkens
|
|
195
|
+
* further on press still reports correctly.
|
|
196
|
+
*/
|
|
197
|
+
const STATE_BASELINE: Record<(typeof INTERACTION_STATES)[number], 'base' | 'hover'> = {
|
|
198
|
+
hover: 'base',
|
|
199
|
+
active: 'hover',
|
|
200
|
+
focus: 'base',
|
|
201
|
+
keyboard: 'base',
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export function evaluate(
|
|
205
|
+
reference: Observation,
|
|
206
|
+
target: Observation,
|
|
207
|
+
declarations: readonly Declaration[],
|
|
208
|
+
): ProbeArtefact {
|
|
209
|
+
const targetById = new Map(target.nodes.map((n) => [n.probe_id, n]));
|
|
210
|
+
const findings: Finding[] = [];
|
|
211
|
+
const suppressed: SuppressedDeviation[] = [];
|
|
212
|
+
const unmatched: string[] = [];
|
|
213
|
+
|
|
214
|
+
for (const ref of reference.nodes) {
|
|
215
|
+
const got = targetById.get(ref.probe_id);
|
|
216
|
+
if (!got) {
|
|
217
|
+
unmatched.push(ref.probe_id);
|
|
218
|
+
findings.push({
|
|
219
|
+
dimension: 'structure',
|
|
220
|
+
probe_id: ref.probe_id,
|
|
221
|
+
state: null,
|
|
222
|
+
property: null,
|
|
223
|
+
expected: 'present',
|
|
224
|
+
observed: 'absent',
|
|
225
|
+
detail:
|
|
226
|
+
`no node carries data-probe-id="${ref.probe_id}" in the target. ` +
|
|
227
|
+
'Style comparison is skipped for this node rather than run against ' +
|
|
228
|
+
'whatever occupies its position.',
|
|
229
|
+
});
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- computed_style, at the base viewport
|
|
234
|
+
for (const p of COMPARED_PROPERTIES) {
|
|
235
|
+
if (ref.base[p] === got.base[p]) continue;
|
|
236
|
+
const declared = isDeclared(declarations, ref.probe_id, 'computed_style', p);
|
|
237
|
+
if (declared) {
|
|
238
|
+
suppressed.push({
|
|
239
|
+
probe_id: ref.probe_id,
|
|
240
|
+
property: p,
|
|
241
|
+
reason: declared.reason,
|
|
242
|
+
expected: ref.base[p] ?? '',
|
|
243
|
+
observed: got.base[p] ?? '',
|
|
244
|
+
});
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
findings.push({
|
|
248
|
+
dimension: 'computed_style',
|
|
249
|
+
probe_id: ref.probe_id,
|
|
250
|
+
state: null,
|
|
251
|
+
property: p,
|
|
252
|
+
expected: ref.base[p] ?? '',
|
|
253
|
+
observed: got.base[p] ?? '',
|
|
254
|
+
detail: `resolved ${p} differs at the base viewport`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- interaction: a state the reference reacts to and the target does not
|
|
259
|
+
for (const state of INTERACTION_STATES) {
|
|
260
|
+
const baseline = STATE_BASELINE[state];
|
|
261
|
+
const refDelta = stateDelta(baseline === 'hover' ? ref.hover : ref.base, ref[state]);
|
|
262
|
+
const gotDelta = stateDelta(baseline === 'hover' ? got.hover : got.base, got[state]);
|
|
263
|
+
const lost = refDelta.filter((p) => !gotDelta.includes(p));
|
|
264
|
+
if (!lost.length) continue;
|
|
265
|
+
if (isDeclared(declarations, ref.probe_id, 'interaction', lost[0]!)) continue;
|
|
266
|
+
findings.push({
|
|
267
|
+
dimension: 'interaction',
|
|
268
|
+
probe_id: ref.probe_id,
|
|
269
|
+
state,
|
|
270
|
+
property: lost[0]!,
|
|
271
|
+
expected: `${state} changes ${lost.join(', ')}`,
|
|
272
|
+
observed: gotDelta.length ? `${state} changes ${gotDelta.join(', ')}` : `${state} changes nothing`,
|
|
273
|
+
detail:
|
|
274
|
+
`the reference responds to ${state} on this node and the target does not. ` +
|
|
275
|
+
'No resting capture at any viewport contains this difference.',
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// --- interaction: a handler whose effect disappeared
|
|
280
|
+
if (ref.click_effect !== null && ref.click_effect !== got.click_effect) {
|
|
281
|
+
findings.push({
|
|
282
|
+
dimension: 'interaction',
|
|
283
|
+
probe_id: ref.probe_id,
|
|
284
|
+
state: 'click',
|
|
285
|
+
property: null,
|
|
286
|
+
expected: ref.click_effect,
|
|
287
|
+
observed: got.click_effect ?? 'no effect',
|
|
288
|
+
detail:
|
|
289
|
+
'clicking this node changes the document in the reference and does not in ' +
|
|
290
|
+
'the target. The element renders identically in both.',
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- viewport_matrix: ONE finding per breakpoint row, not per property.
|
|
295
|
+
// A single removed media rule usually changes several properties at once
|
|
296
|
+
// (this fixture's changes both `flex-direction` and `align-items`), and
|
|
297
|
+
// reporting one finding each would count one defect several times and
|
|
298
|
+
// make "exactly the matching breakpoint row is red" untestable. The row
|
|
299
|
+
// is the unit a reader acts on: this node does not change at this width.
|
|
300
|
+
const perWidth = new Map<string, string[]>();
|
|
301
|
+
for (const w of VIEWPORTS) {
|
|
302
|
+
const key = String(w);
|
|
303
|
+
const refV = ref.viewports[key];
|
|
304
|
+
const gotV = got.viewports[key];
|
|
305
|
+
if (!refV || !gotV) continue;
|
|
306
|
+
const diverging = COMPARED_PROPERTIES.filter(
|
|
307
|
+
(p) =>
|
|
308
|
+
refV[p] !== gotV[p] &&
|
|
309
|
+
// A divergence already visible at the base viewport is a flat
|
|
310
|
+
// style difference, not a breakpoint one. Reporting it in both
|
|
311
|
+
// dimensions would count one defect twice.
|
|
312
|
+
ref.base[p] === got.base[p] &&
|
|
313
|
+
!isDeclared(declarations, ref.probe_id, 'viewport_matrix', p),
|
|
314
|
+
);
|
|
315
|
+
if (diverging.length) perWidth.set(key, diverging);
|
|
316
|
+
}
|
|
317
|
+
if (perWidth.size) {
|
|
318
|
+
const widths = [...perWidth.keys()];
|
|
319
|
+
const first = widths[0]!;
|
|
320
|
+
const properties = perWidth.get(first)!;
|
|
321
|
+
findings.push({
|
|
322
|
+
dimension: 'viewport_matrix',
|
|
323
|
+
probe_id: ref.probe_id,
|
|
324
|
+
state: first,
|
|
325
|
+
property: properties.join(', '),
|
|
326
|
+
expected: properties.map((p) => `${p}: ${ref.viewports[first]?.[p] ?? ''}`).join('; '),
|
|
327
|
+
observed: properties.map((p) => `${p}: ${got.viewports[first]?.[p] ?? ''}`).join('; '),
|
|
328
|
+
detail:
|
|
329
|
+
`this node's layout diverges at ${widths.join('px, ')}px while matching at the base ` +
|
|
330
|
+
'viewport — a breakpoint the target does not cross.',
|
|
331
|
+
widths,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- media_emulation
|
|
336
|
+
const refMotion = stateDelta(ref.base, ref.reduced_motion);
|
|
337
|
+
const gotMotion = stateDelta(got.base, got.reduced_motion);
|
|
338
|
+
const lostMotion = refMotion.filter((p) => !gotMotion.includes(p));
|
|
339
|
+
if (lostMotion.length && !isDeclared(declarations, ref.probe_id, 'media_emulation', lostMotion[0]!)) {
|
|
340
|
+
findings.push({
|
|
341
|
+
dimension: 'media_emulation',
|
|
342
|
+
probe_id: ref.probe_id,
|
|
343
|
+
state: 'reduced-motion',
|
|
344
|
+
property: lostMotion[0]!,
|
|
345
|
+
expected: `reduced motion changes ${lostMotion.join(', ')}`,
|
|
346
|
+
observed: 'reduced motion changes nothing',
|
|
347
|
+
detail: 'the reference presents a reduced-motion alternative on this node and the target does not',
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const dimensions: DimensionRow[] = DIMENSIONS.map((d) => ({
|
|
353
|
+
dimension: d,
|
|
354
|
+
status: 'exercised',
|
|
355
|
+
findings: findings.filter((f) => f.dimension === d).length,
|
|
356
|
+
}));
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
schema: 'ui-conformance/v1',
|
|
360
|
+
generated_at: new Date().toISOString(),
|
|
361
|
+
host_class: resolveHostClass(),
|
|
362
|
+
target: target.source,
|
|
363
|
+
reference: reference.source,
|
|
364
|
+
structure_gate: unmatched.length ? 'stopped' : 'passed',
|
|
365
|
+
unmatched_nodes: unmatched,
|
|
366
|
+
dimensions,
|
|
367
|
+
findings,
|
|
368
|
+
declared_suppressed: suppressed,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The artefact a host that cannot run the probe must still produce. Emitting
|
|
374
|
+
* nothing is indistinguishable from a clean run to everything downstream.
|
|
375
|
+
*/
|
|
376
|
+
export function unavailableArtefact(target: string, reason: string): ProbeArtefact {
|
|
377
|
+
return {
|
|
378
|
+
schema: 'ui-conformance/v1',
|
|
379
|
+
generated_at: new Date().toISOString(),
|
|
380
|
+
host_class: 'unknown',
|
|
381
|
+
target,
|
|
382
|
+
reference: null,
|
|
383
|
+
structure_gate: 'stopped',
|
|
384
|
+
unmatched_nodes: [],
|
|
385
|
+
dimensions: DIMENSIONS.map((d) => ({
|
|
386
|
+
dimension: d,
|
|
387
|
+
status: 'not_applicable' as const,
|
|
388
|
+
findings: null,
|
|
389
|
+
reason: `${d} needs a browser engine this host did not provide: ${reason}`,
|
|
390
|
+
})),
|
|
391
|
+
findings: [],
|
|
392
|
+
declared_suppressed: [],
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function resolveHostClass(): ProbeArtefact['host_class'] {
|
|
397
|
+
if (!chromiumAvailable()) return 'unknown';
|
|
398
|
+
return process.env['CI'] ? 'C' : 'A';
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ------------------------------------------------------------ browser layer
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* ONE loader, memoized, and that is not a style preference. Playwright throws
|
|
405
|
+
* `Requiring @playwright/test second time` when the same process reaches it
|
|
406
|
+
* through both CJS `require` and ESM `import`, so the availability probe and
|
|
407
|
+
* the capture must share a single path. `createRequire` rather than a bare
|
|
408
|
+
* `require`, because this file is ESM and a bare `require` is undefined
|
|
409
|
+
* under tsx.
|
|
410
|
+
*/
|
|
411
|
+
let playwrightModule: typeof PlaywrightTest | null | undefined;
|
|
412
|
+
function loadPlaywright(): typeof PlaywrightTest | null {
|
|
413
|
+
if (playwrightModule !== undefined) return playwrightModule;
|
|
414
|
+
try {
|
|
415
|
+
const req = createRequire(import.meta.url);
|
|
416
|
+
playwrightModule = req('@playwright/test') as typeof PlaywrightTest;
|
|
417
|
+
} catch {
|
|
418
|
+
playwrightModule = null;
|
|
419
|
+
}
|
|
420
|
+
return playwrightModule;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function chromiumAvailable(): boolean {
|
|
424
|
+
try {
|
|
425
|
+
// Resolving the module is not enough — the binaries are a separate
|
|
426
|
+
// install, and this package declares the runner without installing them.
|
|
427
|
+
const pw = loadPlaywright();
|
|
428
|
+
const exe = pw?.chromium?.executablePath?.();
|
|
429
|
+
return Boolean(exe && fs.existsSync(exe));
|
|
430
|
+
} catch {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const READ_STYLES = (el: Element, props: readonly string[]): Record<string, string> => {
|
|
436
|
+
const cs = getComputedStyle(el);
|
|
437
|
+
const out: Record<string, string> = {};
|
|
438
|
+
for (const p of props) out[p] = cs.getPropertyValue(p).trim();
|
|
439
|
+
return out;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
export async function captureVariant(indexHtml: string): Promise<Observation> {
|
|
443
|
+
const pw = loadPlaywright();
|
|
444
|
+
if (!pw) throw new Error('captureVariant: @playwright/test is not resolvable on this host');
|
|
445
|
+
const browser = await pw.chromium.launch();
|
|
446
|
+
const url = pathToFileURL(indexHtml).href;
|
|
447
|
+
const props = [...COMPARED_PROPERTIES];
|
|
448
|
+
const nodes: NodeObservation[] = [];
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
const page = await browser.newPage({ viewport: { width: VIEWPORTS[0], height: 900 } });
|
|
452
|
+
await page.goto(url);
|
|
453
|
+
const ids = await page.$$eval('[data-probe-id]', (els) =>
|
|
454
|
+
els.map((e) => e.getAttribute('data-probe-id') ?? ''),
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
for (const id of ids.filter(Boolean).sort()) {
|
|
458
|
+
const sel = `[data-probe-id="${id}"]`;
|
|
459
|
+
await page.setViewportSize({ width: VIEWPORTS[0], height: 900 });
|
|
460
|
+
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
|
461
|
+
await page.reload();
|
|
462
|
+
|
|
463
|
+
const base = await page.$eval(sel, READ_STYLES, props);
|
|
464
|
+
|
|
465
|
+
await page.hover(sel).catch(() => undefined);
|
|
466
|
+
const hover = await page.$eval(sel, READ_STYLES, props);
|
|
467
|
+
await page.mouse.move(0, 0);
|
|
468
|
+
|
|
469
|
+
await page.$eval(sel, (el) => (el as HTMLElement).focus?.());
|
|
470
|
+
const focus = await page.$eval(sel, READ_STYLES, props);
|
|
471
|
+
await page.$eval(sel, (el) => (el as HTMLElement).blur?.());
|
|
472
|
+
|
|
473
|
+
const box = await page.$eval(sel, (el) => {
|
|
474
|
+
const r = el.getBoundingClientRect();
|
|
475
|
+
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
|
476
|
+
});
|
|
477
|
+
await page.mouse.move(box.x, box.y);
|
|
478
|
+
await page.mouse.down();
|
|
479
|
+
const active = await page.$eval(sel, READ_STYLES, props);
|
|
480
|
+
await page.mouse.up();
|
|
481
|
+
await page.reload();
|
|
482
|
+
|
|
483
|
+
const keyboard = await captureKeyboardState(page, id, sel, props);
|
|
484
|
+
|
|
485
|
+
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
486
|
+
const reducedMotion = await page.$eval(sel, READ_STYLES, props);
|
|
487
|
+
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
|
488
|
+
|
|
489
|
+
const viewports: Record<string, StyleMap> = {};
|
|
490
|
+
for (const w of VIEWPORTS) {
|
|
491
|
+
await page.setViewportSize({ width: w, height: 900 });
|
|
492
|
+
viewports[String(w)] = await page.$eval(sel, READ_STYLES, props);
|
|
493
|
+
}
|
|
494
|
+
await page.setViewportSize({ width: VIEWPORTS[0], height: 900 });
|
|
495
|
+
|
|
496
|
+
const clickEffect = await captureClickEffect(page, sel);
|
|
497
|
+
|
|
498
|
+
nodes.push({
|
|
499
|
+
probe_id: id,
|
|
500
|
+
base,
|
|
501
|
+
hover,
|
|
502
|
+
focus,
|
|
503
|
+
active,
|
|
504
|
+
keyboard,
|
|
505
|
+
reduced_motion: reducedMotion,
|
|
506
|
+
viewports,
|
|
507
|
+
click_effect: clickEffect,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
await page.close();
|
|
511
|
+
} finally {
|
|
512
|
+
await browser.close();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return { variant: path.basename(path.dirname(indexHtml)), source: indexHtml, nodes };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Keyboard focus, reached by actually pressing Tab. `element.focus()` does not
|
|
520
|
+
* set `:focus-visible` in Chromium when the last input was not a keyboard, so a
|
|
521
|
+
* programmatic focus would silently report the focus ring as absent.
|
|
522
|
+
*/
|
|
523
|
+
async function captureKeyboardState(
|
|
524
|
+
page: Page,
|
|
525
|
+
id: string,
|
|
526
|
+
sel: string,
|
|
527
|
+
props: readonly string[],
|
|
528
|
+
): Promise<StyleMap> {
|
|
529
|
+
await page.reload();
|
|
530
|
+
for (let i = 0; i < 12; i += 1) {
|
|
531
|
+
await page.keyboard.press('Tab');
|
|
532
|
+
const hit = await page.evaluate(
|
|
533
|
+
(probeId) => document.activeElement?.getAttribute('data-probe-id') === probeId,
|
|
534
|
+
id,
|
|
535
|
+
);
|
|
536
|
+
if (hit) break;
|
|
537
|
+
}
|
|
538
|
+
const styles = await page.$eval(sel, READ_STYLES, props);
|
|
539
|
+
await page.reload();
|
|
540
|
+
return styles;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Click, then serialize what CHANGED about the document — a delta, never an
|
|
545
|
+
* absolute snapshot.
|
|
546
|
+
*
|
|
547
|
+
* The absolute form was the first implementation and it was wrong in a way the
|
|
548
|
+
* fixture caught: the snapshot listed every `data-probe-id` on the page, so
|
|
549
|
+
* renaming ONE unrelated element changed the recorded click effect of EVERY
|
|
550
|
+
* node, and the renamed variant produced a spurious interaction finding on a
|
|
551
|
+
* button whose handler was untouched. A delta is scoped to the nodes the click
|
|
552
|
+
* actually moved, so an unrelated rename is invisible here and stays the
|
|
553
|
+
* structure dimension's business.
|
|
554
|
+
*
|
|
555
|
+
* A node whose click changes nothing reports null — the correct reading for a
|
|
556
|
+
* paragraph, and the defect reading for a button.
|
|
557
|
+
*/
|
|
558
|
+
async function captureClickEffect(page: Page, sel: string): Promise<string | null> {
|
|
559
|
+
const snapshot = (): Promise<Record<string, string>> =>
|
|
560
|
+
page.evaluate(() =>
|
|
561
|
+
Object.fromEntries(
|
|
562
|
+
[...document.querySelectorAll('[data-probe-id]')].map((e) => [
|
|
563
|
+
e.getAttribute('data-probe-id') ?? '',
|
|
564
|
+
`${e.hasAttribute('hidden') ? 'hidden' : 'shown'}/${e.getAttribute('aria-expanded') ?? '-'}`,
|
|
565
|
+
]),
|
|
566
|
+
),
|
|
567
|
+
);
|
|
568
|
+
await page.reload();
|
|
569
|
+
const before = await snapshot();
|
|
570
|
+
await page.click(sel, { timeout: 2000 }).catch(() => undefined);
|
|
571
|
+
const after = await snapshot();
|
|
572
|
+
await page.reload();
|
|
573
|
+
|
|
574
|
+
const changed = Object.keys({ ...before, ...after })
|
|
575
|
+
.filter((id) => before[id] !== after[id])
|
|
576
|
+
.sort()
|
|
577
|
+
.map((id) => `${id}: ${before[id] ?? 'absent'} -> ${after[id] ?? 'absent'}`);
|
|
578
|
+
return changed.length ? changed.join('; ') : null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// -------------------------------------------------------------------- CLI
|
|
582
|
+
|
|
583
|
+
const arg = (name: string): string | undefined => {
|
|
584
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
585
|
+
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
async function main(): Promise<number> {
|
|
589
|
+
const target = arg('target');
|
|
590
|
+
if (!target) {
|
|
591
|
+
process.stderr.write('usage: ui_conformance_probe --target <index.html> [--reference <index.html>] [--out <file>]\n');
|
|
592
|
+
return 1;
|
|
593
|
+
}
|
|
594
|
+
const out = arg('out') ?? path.join('agents', 'runtime', 'state', 'ui-conformance.json');
|
|
595
|
+
const referencePath = arg('reference');
|
|
596
|
+
|
|
597
|
+
let artefact: ProbeArtefact;
|
|
598
|
+
if (!chromiumAvailable()) {
|
|
599
|
+
artefact = unavailableArtefact(target, 'no Chromium binary resolved from @playwright/test');
|
|
600
|
+
} else if (!referencePath) {
|
|
601
|
+
artefact = unavailableArtefact(target, 'no --reference given; a comparison needs an intended implementation');
|
|
602
|
+
for (const row of artefact.dimensions) {
|
|
603
|
+
row.reason = `${row.dimension} needs a --reference to compare against; none was given`;
|
|
604
|
+
}
|
|
605
|
+
} else {
|
|
606
|
+
artefact = evaluate(await captureVariant(referencePath), await captureVariant(target), loadDeclarations(path.join(path.dirname(target), 'conformance.declared.json')));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
610
|
+
fs.writeFileSync(out, `${JSON.stringify(artefact, null, 2)}\n`);
|
|
611
|
+
for (const row of artefact.dimensions) {
|
|
612
|
+
const count = row.findings === null ? `not-applicable — ${row.reason ?? ''}` : String(row.findings);
|
|
613
|
+
process.stdout.write(`${row.dimension}: ${count}\n`);
|
|
614
|
+
}
|
|
615
|
+
process.stdout.write(`structure_gate: ${artefact.structure_gate}\n`);
|
|
616
|
+
return 0;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export const _HERE = fileURLToPath(import.meta.url);
|
|
620
|
+
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
|
621
|
+
main().then((c) => process.exit(c));
|
|
622
|
+
}
|
package/src/server/io/yamlIO.ts
CHANGED
|
@@ -365,6 +365,49 @@ function replaceFlatDottedKey(
|
|
|
365
365
|
return { found: true, body: lines.join('\n') };
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Drop every top-level `a.b.c:` line for a path whose NESTED form the caller
|
|
370
|
+
* has just resolved.
|
|
371
|
+
*
|
|
372
|
+
* Which reader this preserves, because the two forms can disagree and only one
|
|
373
|
+
* of them is ever consulted: by YAML semantics `a.b.c` is reached by walking
|
|
374
|
+
* the `a` mapping, so a top-level key literally named "a.b.c" is a DIFFERENT
|
|
375
|
+
* path that no lookup of the dotted one visits. Removing it therefore changes
|
|
376
|
+
* no reader's answer — strict and lenient alike already resolve through the
|
|
377
|
+
* nesting — while leaving it keeps the one line a human scanning the file
|
|
378
|
+
* reads as the value, saying something the program never agreed with. It
|
|
379
|
+
* cannot self-heal either: the duplicate-key pass sees a single occurrence,
|
|
380
|
+
* and `mergeIntoTemplate` writes the nested side, so the line is permanent.
|
|
381
|
+
*
|
|
382
|
+
* The nested twin IS the safety argument, not a convenience. A flat `a.b:`
|
|
383
|
+
* standing ALONE is a key somebody may have meant — `mergeIntoTemplate`'s own
|
|
384
|
+
* fallback branch writes exactly that shape for a path the template lacks — and
|
|
385
|
+
* this function is never reached for one: the caller takes the
|
|
386
|
+
* `replaceFlatDottedKey` branch instead, which updates that line in place.
|
|
387
|
+
*
|
|
388
|
+
* Two bounds, each of which turns the sweep into a data-loss bug if dropped:
|
|
389
|
+
*
|
|
390
|
+
* - Two segments minimum. A one-segment path joins to a name with no dot, so
|
|
391
|
+
* the match below would hit the very line `replaceScalar` just wrote and
|
|
392
|
+
* delete the key the caller asked to set — a rewrite silently becoming a
|
|
393
|
+
* removal. Same guard, same reason, as `replaceFlatDottedKey`.
|
|
394
|
+
* - Top level only, the bound `replaceFlatDottedKey` also draws: an INDENTED
|
|
395
|
+
* `a.b:` is somebody else's child key (`parent:` / ` a.b:` means
|
|
396
|
+
* `parent['a.b']`), an unrelated path this sweep has no claim on.
|
|
397
|
+
*/
|
|
398
|
+
function dropFlatDottedTwins(template: string, dottedPath: string[]): string {
|
|
399
|
+
if (dottedPath.length < 2) return template;
|
|
400
|
+
const flat = dottedPath.join('.');
|
|
401
|
+
const lines = template.split('\n');
|
|
402
|
+
const kept = lines.filter((line) => {
|
|
403
|
+
if (line.length !== line.trimStart().length) return true;
|
|
404
|
+
const m = /^([A-Za-z_][A-Za-z0-9_.-]*)\s*:/.exec(line);
|
|
405
|
+
return m === null || m[1] !== flat;
|
|
406
|
+
});
|
|
407
|
+
if (kept.length === lines.length) return template;
|
|
408
|
+
return kept.join('\n');
|
|
409
|
+
}
|
|
410
|
+
|
|
368
411
|
/**
|
|
369
412
|
* Apply every leaf change from `newValues` to `templateBody`. Paths that
|
|
370
413
|
* are not present in the template are appended at the end. Comments are
|
|
@@ -383,6 +426,11 @@ export function mergeIntoTemplate(templateBody: string, newValues: Record<string
|
|
|
383
426
|
// see `findScalarLine`. Writing the value it already holds is a hit.
|
|
384
427
|
if (findScalarLine(body.split('\n'), sections, leaf) !== -1) {
|
|
385
428
|
body = replaceScalar(body, entry.path, entry.value);
|
|
429
|
+
// The nested form won, so any flat twin of this same path is dead
|
|
430
|
+
// and stays dead. The probe above is what licenses the removal:
|
|
431
|
+
// without a nested key to resolve through, a flat `a.b:` is the
|
|
432
|
+
// real entry and the branch below owns it.
|
|
433
|
+
body = dropFlatDottedTwins(body, entry.path);
|
|
386
434
|
continue;
|
|
387
435
|
}
|
|
388
436
|
// Not in the template's nested form. It may still be here as a flat
|