@clawplays/ospec-cli 0.1.1

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.
Files changed (191) hide show
  1. package/.ospec/templates/hooks/post-merge +8 -0
  2. package/.ospec/templates/hooks/pre-commit +8 -0
  3. package/LICENSE +21 -0
  4. package/README.md +549 -0
  5. package/README.zh-CN.md +549 -0
  6. package/assets/for-ai/en-US/ai-guide.md +98 -0
  7. package/assets/for-ai/en-US/execution-protocol.md +64 -0
  8. package/assets/for-ai/zh-CN/ai-guide.md +102 -0
  9. package/assets/for-ai/zh-CN/execution-protocol.md +68 -0
  10. package/assets/git-hooks/post-merge +12 -0
  11. package/assets/git-hooks/pre-commit +12 -0
  12. package/assets/global-skills/claude/ospec-change/SKILL.md +116 -0
  13. package/assets/global-skills/codex/ospec-change/SKILL.md +117 -0
  14. package/assets/global-skills/codex/ospec-change/agents/openai.yaml +7 -0
  15. package/assets/global-skills/codex/ospec-change/skill.yaml +19 -0
  16. package/assets/project-conventions/en-US/development-guide.md +32 -0
  17. package/assets/project-conventions/en-US/naming-conventions.md +51 -0
  18. package/assets/project-conventions/en-US/skill-conventions.md +40 -0
  19. package/assets/project-conventions/en-US/workflow-conventions.md +70 -0
  20. package/assets/project-conventions/zh-CN/development-guide.md +32 -0
  21. package/assets/project-conventions/zh-CN/naming-conventions.md +51 -0
  22. package/assets/project-conventions/zh-CN/skill-conventions.md +40 -0
  23. package/assets/project-conventions/zh-CN/workflow-conventions.md +74 -0
  24. package/dist/adapters/codex-stitch-adapter.js +420 -0
  25. package/dist/adapters/gemini-stitch-adapter.js +408 -0
  26. package/dist/adapters/playwright-checkpoint-adapter.js +2260 -0
  27. package/dist/advanced/BatchOperations.d.ts +36 -0
  28. package/dist/advanced/BatchOperations.js +159 -0
  29. package/dist/advanced/CachingLayer.d.ts +66 -0
  30. package/dist/advanced/CachingLayer.js +136 -0
  31. package/dist/advanced/FeatureUpdater.d.ts +46 -0
  32. package/dist/advanced/FeatureUpdater.js +151 -0
  33. package/dist/advanced/PerformanceMonitor.d.ts +52 -0
  34. package/dist/advanced/PerformanceMonitor.js +129 -0
  35. package/dist/advanced/StatePersistence.d.ts +61 -0
  36. package/dist/advanced/StatePersistence.js +168 -0
  37. package/dist/advanced/index.d.ts +14 -0
  38. package/dist/advanced/index.js +22 -0
  39. package/dist/cli/commands/config.d.ts +5 -0
  40. package/dist/cli/commands/config.js +6 -0
  41. package/dist/cli/commands/feature.d.ts +5 -0
  42. package/dist/cli/commands/feature.js +6 -0
  43. package/dist/cli/commands/index.d.ts +5 -0
  44. package/dist/cli/commands/index.js +6 -0
  45. package/dist/cli/commands/project.d.ts +5 -0
  46. package/dist/cli/commands/project.js +6 -0
  47. package/dist/cli/commands/validate.d.ts +5 -0
  48. package/dist/cli/commands/validate.js +6 -0
  49. package/dist/cli/index.d.ts +5 -0
  50. package/dist/cli/index.js +6 -0
  51. package/dist/cli.d.ts +3 -0
  52. package/dist/cli.js +1007 -0
  53. package/dist/commands/ArchiveCommand.d.ts +14 -0
  54. package/dist/commands/ArchiveCommand.js +241 -0
  55. package/dist/commands/BaseCommand.d.ts +33 -0
  56. package/dist/commands/BaseCommand.js +46 -0
  57. package/dist/commands/BatchCommand.d.ts +5 -0
  58. package/dist/commands/BatchCommand.js +42 -0
  59. package/dist/commands/ChangesCommand.d.ts +3 -0
  60. package/dist/commands/ChangesCommand.js +71 -0
  61. package/dist/commands/DocsCommand.d.ts +5 -0
  62. package/dist/commands/DocsCommand.js +118 -0
  63. package/dist/commands/FinalizeCommand.d.ts +3 -0
  64. package/dist/commands/FinalizeCommand.js +24 -0
  65. package/dist/commands/IndexCommand.d.ts +5 -0
  66. package/dist/commands/IndexCommand.js +57 -0
  67. package/dist/commands/InitCommand.d.ts +5 -0
  68. package/dist/commands/InitCommand.js +65 -0
  69. package/dist/commands/NewCommand.d.ts +11 -0
  70. package/dist/commands/NewCommand.js +262 -0
  71. package/dist/commands/PluginsCommand.d.ts +58 -0
  72. package/dist/commands/PluginsCommand.js +2491 -0
  73. package/dist/commands/ProgressCommand.d.ts +5 -0
  74. package/dist/commands/ProgressCommand.js +103 -0
  75. package/dist/commands/QueueCommand.d.ts +10 -0
  76. package/dist/commands/QueueCommand.js +147 -0
  77. package/dist/commands/RunCommand.d.ts +13 -0
  78. package/dist/commands/RunCommand.js +200 -0
  79. package/dist/commands/SkillCommand.d.ts +31 -0
  80. package/dist/commands/SkillCommand.js +1216 -0
  81. package/dist/commands/SkillsCommand.d.ts +5 -0
  82. package/dist/commands/SkillsCommand.js +68 -0
  83. package/dist/commands/StatusCommand.d.ts +6 -0
  84. package/dist/commands/StatusCommand.js +140 -0
  85. package/dist/commands/UpdateCommand.d.ts +8 -0
  86. package/dist/commands/UpdateCommand.js +251 -0
  87. package/dist/commands/VerifyCommand.d.ts +5 -0
  88. package/dist/commands/VerifyCommand.js +278 -0
  89. package/dist/commands/WorkflowCommand.d.ts +12 -0
  90. package/dist/commands/WorkflowCommand.js +150 -0
  91. package/dist/commands/index.d.ts +43 -0
  92. package/dist/commands/index.js +85 -0
  93. package/dist/core/constants.d.ts +41 -0
  94. package/dist/core/constants.js +73 -0
  95. package/dist/core/errors.d.ts +36 -0
  96. package/dist/core/errors.js +72 -0
  97. package/dist/core/index.d.ts +7 -0
  98. package/dist/core/index.js +23 -0
  99. package/dist/core/types.d.ts +369 -0
  100. package/dist/core/types.js +3 -0
  101. package/dist/index.d.ts +11 -0
  102. package/dist/index.js +27 -0
  103. package/dist/presets/ProjectPresets.d.ts +41 -0
  104. package/dist/presets/ProjectPresets.js +190 -0
  105. package/dist/scaffolds/ProjectScaffoldPresets.d.ts +20 -0
  106. package/dist/scaffolds/ProjectScaffoldPresets.js +151 -0
  107. package/dist/services/ConfigManager.d.ts +14 -0
  108. package/dist/services/ConfigManager.js +386 -0
  109. package/dist/services/FeatureManager.d.ts +5 -0
  110. package/dist/services/FeatureManager.js +6 -0
  111. package/dist/services/FileService.d.ts +21 -0
  112. package/dist/services/FileService.js +152 -0
  113. package/dist/services/IndexBuilder.d.ts +12 -0
  114. package/dist/services/IndexBuilder.js +130 -0
  115. package/dist/services/Logger.d.ts +20 -0
  116. package/dist/services/Logger.js +48 -0
  117. package/dist/services/ProjectAssetRegistry.d.ts +12 -0
  118. package/dist/services/ProjectAssetRegistry.js +96 -0
  119. package/dist/services/ProjectAssetService.d.ts +49 -0
  120. package/dist/services/ProjectAssetService.js +223 -0
  121. package/dist/services/ProjectScaffoldCommandService.d.ts +73 -0
  122. package/dist/services/ProjectScaffoldCommandService.js +159 -0
  123. package/dist/services/ProjectScaffoldService.d.ts +44 -0
  124. package/dist/services/ProjectScaffoldService.js +507 -0
  125. package/dist/services/ProjectService.d.ts +209 -0
  126. package/dist/services/ProjectService.js +13239 -0
  127. package/dist/services/QueueService.d.ts +17 -0
  128. package/dist/services/QueueService.js +142 -0
  129. package/dist/services/RunService.d.ts +40 -0
  130. package/dist/services/RunService.js +420 -0
  131. package/dist/services/SkillParser.d.ts +30 -0
  132. package/dist/services/SkillParser.js +88 -0
  133. package/dist/services/StateManager.d.ts +16 -0
  134. package/dist/services/StateManager.js +127 -0
  135. package/dist/services/TemplateEngine.d.ts +43 -0
  136. package/dist/services/TemplateEngine.js +119 -0
  137. package/dist/services/TemplateGenerator.d.ts +40 -0
  138. package/dist/services/TemplateGenerator.js +273 -0
  139. package/dist/services/ValidationService.d.ts +19 -0
  140. package/dist/services/ValidationService.js +44 -0
  141. package/dist/services/Validator.d.ts +5 -0
  142. package/dist/services/Validator.js +6 -0
  143. package/dist/services/index.d.ts +52 -0
  144. package/dist/services/index.js +91 -0
  145. package/dist/services/templates/ExecutionTemplateBuilder.d.ts +12 -0
  146. package/dist/services/templates/ExecutionTemplateBuilder.js +300 -0
  147. package/dist/services/templates/ProjectTemplateBuilder.d.ts +38 -0
  148. package/dist/services/templates/ProjectTemplateBuilder.js +1897 -0
  149. package/dist/services/templates/TemplateBuilderBase.d.ts +19 -0
  150. package/dist/services/templates/TemplateBuilderBase.js +60 -0
  151. package/dist/services/templates/TemplateInputFactory.d.ts +16 -0
  152. package/dist/services/templates/TemplateInputFactory.js +298 -0
  153. package/dist/services/templates/templateTypes.d.ts +90 -0
  154. package/dist/services/templates/templateTypes.js +3 -0
  155. package/dist/tools/build-index.js +632 -0
  156. package/dist/utils/DateUtils.d.ts +18 -0
  157. package/dist/utils/DateUtils.js +40 -0
  158. package/dist/utils/PathUtils.d.ts +9 -0
  159. package/dist/utils/PathUtils.js +66 -0
  160. package/dist/utils/StringUtils.d.ts +26 -0
  161. package/dist/utils/StringUtils.js +47 -0
  162. package/dist/utils/helpers.d.ts +5 -0
  163. package/dist/utils/helpers.js +6 -0
  164. package/dist/utils/index.d.ts +7 -0
  165. package/dist/utils/index.js +23 -0
  166. package/dist/utils/logger.d.ts +5 -0
  167. package/dist/utils/logger.js +6 -0
  168. package/dist/utils/path.d.ts +5 -0
  169. package/dist/utils/path.js +6 -0
  170. package/dist/utils/subcommandHelp.d.ts +11 -0
  171. package/dist/utils/subcommandHelp.js +119 -0
  172. package/dist/workflow/ArchiveGate.d.ts +30 -0
  173. package/dist/workflow/ArchiveGate.js +93 -0
  174. package/dist/workflow/ConfigurableWorkflow.d.ts +89 -0
  175. package/dist/workflow/ConfigurableWorkflow.js +186 -0
  176. package/dist/workflow/HookSystem.d.ts +38 -0
  177. package/dist/workflow/HookSystem.js +66 -0
  178. package/dist/workflow/IndexRegenerator.d.ts +49 -0
  179. package/dist/workflow/IndexRegenerator.js +147 -0
  180. package/dist/workflow/PluginWorkflowComposer.d.ts +138 -0
  181. package/dist/workflow/PluginWorkflowComposer.js +239 -0
  182. package/dist/workflow/SkillUpdateEngine.d.ts +26 -0
  183. package/dist/workflow/SkillUpdateEngine.js +113 -0
  184. package/dist/workflow/VerificationSystem.d.ts +24 -0
  185. package/dist/workflow/VerificationSystem.js +116 -0
  186. package/dist/workflow/WorkflowEngine.d.ts +15 -0
  187. package/dist/workflow/WorkflowEngine.js +57 -0
  188. package/dist/workflow/index.d.ts +19 -0
  189. package/dist/workflow/index.js +32 -0
  190. package/package.json +78 -0
  191. package/scripts/postinstall.js +43 -0
@@ -0,0 +1,2260 @@
1
+ "use strict";
2
+ const fs = require("fs-extra");
3
+ const path = require("path");
4
+ const yaml = require("js-yaml");
5
+ const matter = require("gray-matter");
6
+ const { spawn, spawnSync } = require("child_process");
7
+ const { createRequire } = require("module");
8
+
9
+ function parseArgs(argv) {
10
+ const args = argv.slice(2);
11
+ const parsed = {
12
+ changePath: "",
13
+ projectPath: "",
14
+ };
15
+ for (let index = 0; index < args.length; index += 1) {
16
+ const arg = String(args[index] || "").trim();
17
+ if (arg === "--change") {
18
+ parsed.changePath = path.resolve(String(args[index + 1] || "").trim());
19
+ index += 1;
20
+ continue;
21
+ }
22
+ if (arg === "--project") {
23
+ parsed.projectPath = path.resolve(String(args[index + 1] || "").trim());
24
+ index += 1;
25
+ continue;
26
+ }
27
+ }
28
+ if (!parsed.changePath || !parsed.projectPath) {
29
+ throw new Error("Usage: node playwright-checkpoint-adapter.js --change <change-path> --project <project-path>");
30
+ }
31
+ return parsed;
32
+ }
33
+
34
+ function firstString(...values) {
35
+ return values.find(value => typeof value === "string" && value.trim().length > 0)?.trim() || "";
36
+ }
37
+
38
+ const ISSUE_METADATA_BY_CODE = {
39
+ auth_failed: { category: "runtime", severity: "error" },
40
+ auth_storage_state_missing: { category: "runtime", severity: "error" },
41
+ auth_storage_state_unconfigured: { category: "config", severity: "error" },
42
+ api_json_failed: { category: "data", severity: "error" },
43
+ api_status_failed: { category: "data", severity: "error" },
44
+ api_text_failed: { category: "data", severity: "error" },
45
+ assert_command_failed: { category: "flow", severity: "error" },
46
+ baseline_missing: { category: "config", severity: "error" },
47
+ baseline_size_mismatch: { category: "config", severity: "error" },
48
+ checkpoint_runtime_failed: { category: "runtime", severity: "error" },
49
+ clipped_text: { category: "typography", severity: "error" },
50
+ color_config_invalid: { category: "config", severity: "error" },
51
+ color_mismatch: { category: "design-token", severity: "error" },
52
+ color_selector_missing: { category: "design-token", severity: "error" },
53
+ color_value_unresolved: { category: "design-token", severity: "error" },
54
+ contrast_failed: { category: "design-token", severity: "error" },
55
+ covered_element: { category: "visibility", severity: "error" },
56
+ element_overlap: { category: "layout", severity: "error" },
57
+ flow_failed: { category: "flow", severity: "error" },
58
+ flows_config_missing: { category: "config", severity: "error" },
59
+ font_family_mismatch: { category: "typography", severity: "error" },
60
+ font_size_mismatch: { category: "typography", severity: "error" },
61
+ font_weight_mismatch: { category: "typography", severity: "error" },
62
+ horizontal_overflow: { category: "responsive", severity: "error" },
63
+ json_assertion_failed: { category: "data", severity: "error" },
64
+ line_height_mismatch: { category: "typography", severity: "error" },
65
+ overlap_selector_hidden: { category: "visibility", severity: "error" },
66
+ overlap_selector_missing: { category: "layout", severity: "error" },
67
+ playwright_missing: { category: "runtime", severity: "error" },
68
+ readiness_failed: { category: "runtime", severity: "error" },
69
+ required_selector_hidden: { category: "visibility", severity: "error" },
70
+ required_selector_missing: { category: "visibility", severity: "error" },
71
+ required_selector_offscreen: { category: "responsive", severity: "error" },
72
+ route_failed: { category: "runtime", severity: "error" },
73
+ routes_config_missing: { category: "config", severity: "error" },
74
+ text_wrap_failed: { category: "typography", severity: "error" },
75
+ typography_selector_missing: { category: "typography", severity: "error" },
76
+ visual_diff_failed: { category: "layout", severity: "error" },
77
+ visual_diff_unavailable: { category: "config", severity: "error" },
78
+ checkpoint_steps_missing: { category: "config", severity: "error" },
79
+ };
80
+
81
+ const ISSUE_EVIDENCE_KEYS = new Set([
82
+ "actual",
83
+ "background",
84
+ "covered_by",
85
+ "details",
86
+ "diff_ratio",
87
+ "distance",
88
+ "estimated_lines",
89
+ "expected",
90
+ "first",
91
+ "foreground",
92
+ "min_ratio",
93
+ "overlap_area",
94
+ "path",
95
+ "property",
96
+ "ratio",
97
+ "rule",
98
+ "second",
99
+ "text",
100
+ "tolerance",
101
+ "url",
102
+ ]);
103
+
104
+ function inferIssueCategory(code, extra = {}) {
105
+ const normalizedCode = String(code || "").trim();
106
+ if (normalizedCode && ISSUE_METADATA_BY_CODE[normalizedCode]?.category) {
107
+ return ISSUE_METADATA_BY_CODE[normalizedCode].category;
108
+ }
109
+ if (extra.flow) {
110
+ return "flow";
111
+ }
112
+ if (extra.route || extra.viewport) {
113
+ return "layout";
114
+ }
115
+ return "runtime";
116
+ }
117
+
118
+ function inferIssueSeverity(code, category) {
119
+ const normalizedCode = String(code || "").trim();
120
+ if (normalizedCode && ISSUE_METADATA_BY_CODE[normalizedCode]?.severity) {
121
+ return ISSUE_METADATA_BY_CODE[normalizedCode].severity;
122
+ }
123
+ return category === "config" ? "error" : "error";
124
+ }
125
+
126
+ function normalizeIssueEvidence(extra) {
127
+ const evidence = isObject(extra?.evidence) ? { ...extra.evidence } : {};
128
+ for (const [key, value] of Object.entries(extra || {})) {
129
+ if (!ISSUE_EVIDENCE_KEYS.has(key)) {
130
+ continue;
131
+ }
132
+ if (value === undefined || value === null || value === "") {
133
+ continue;
134
+ }
135
+ evidence[key] = value;
136
+ }
137
+ return Object.keys(evidence).length > 0 ? evidence : undefined;
138
+ }
139
+
140
+ function createIssue(message, extra = {}) {
141
+ const normalizedExtra = isObject(extra) ? extra : {};
142
+ const code = firstString(normalizedExtra.code, "checkpoint_issue");
143
+ const category = firstString(normalizedExtra.category, inferIssueCategory(code, normalizedExtra));
144
+ const severity = firstString(normalizedExtra.severity, inferIssueSeverity(code, category));
145
+ const evidence = normalizeIssueEvidence(normalizedExtra);
146
+ const passthrough = Object.fromEntries(Object.entries(normalizedExtra)
147
+ .filter(([key]) => !["category", "code", "evidence", "flow", "message", "route", "selector", "severity", "step", "viewport"].includes(key)));
148
+ return {
149
+ message: String(message || "").trim(),
150
+ code,
151
+ category,
152
+ severity,
153
+ step: firstString(normalizedExtra.step),
154
+ route: firstString(normalizedExtra.route),
155
+ viewport: firstString(normalizedExtra.viewport),
156
+ flow: firstString(normalizedExtra.flow),
157
+ selector: firstString(normalizedExtra.selector),
158
+ ...(evidence ? { evidence } : {}),
159
+ ...passthrough,
160
+ };
161
+ }
162
+
163
+ function createUiIssue(message, extra = {}) {
164
+ return createIssue(message, {
165
+ step: "checkpoint_ui_review",
166
+ ...extra,
167
+ });
168
+ }
169
+
170
+ function createRouteIssue(routeName, viewportName, message, extra = {}) {
171
+ return createUiIssue(message, {
172
+ route: routeName,
173
+ viewport: viewportName,
174
+ ...extra,
175
+ });
176
+ }
177
+
178
+ function createFlowIssue(flowName, message, extra = {}) {
179
+ return createIssue(message, {
180
+ step: "checkpoint_flow_check",
181
+ flow: flowName,
182
+ ...extra,
183
+ });
184
+ }
185
+
186
+ function createRuntimeIssue(message, extra = {}) {
187
+ return createIssue(message, {
188
+ step: firstString(extra?.step, "checkpoint_runtime"),
189
+ ...extra,
190
+ });
191
+ }
192
+
193
+ function debugLog(...parts) {
194
+ if (process.env.OSPEC_CHECKPOINT_DEBUG !== "1") {
195
+ return;
196
+ }
197
+ process.stderr.write(`[checkpoint-debug] ${parts.map(part => String(part)).join(" ")}\n`);
198
+ }
199
+
200
+ function normalizeStatus(value, fallback = "pending") {
201
+ const normalized = String(value || "").trim().toLowerCase();
202
+ if (normalized === "pass") {
203
+ return "passed";
204
+ }
205
+ if (normalized === "fail" || normalized === "error") {
206
+ return "failed";
207
+ }
208
+ if (normalized === "passed" || normalized === "failed" || normalized === "pending" || normalized === "skipped") {
209
+ return normalized;
210
+ }
211
+ return fallback;
212
+ }
213
+
214
+ function sanitizeName(value, fallback) {
215
+ const normalized = String(value || "").trim().toLowerCase().replace(/[^a-z0-9-_]+/g, "-").replace(/^-+|-+$/g, "");
216
+ return normalized || fallback;
217
+ }
218
+
219
+ function isObject(value) {
220
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
221
+ }
222
+
223
+ function readStringList(value) {
224
+ const rawValues = Array.isArray(value)
225
+ ? value
226
+ : value === undefined || value === null || value === false
227
+ ? []
228
+ : [value];
229
+ return Array.from(new Set(rawValues
230
+ .map(item => String(item || "").trim())
231
+ .filter(Boolean)));
232
+ }
233
+
234
+ function toOptionalNumber(value) {
235
+ if (value === undefined || value === null || value === "") {
236
+ return null;
237
+ }
238
+ const numeric = Number(value);
239
+ return Number.isFinite(numeric) ? numeric : null;
240
+ }
241
+
242
+ function clampByte(value) {
243
+ if (!Number.isFinite(value)) {
244
+ return 0;
245
+ }
246
+ return Math.max(0, Math.min(255, Math.round(value)));
247
+ }
248
+
249
+ function normalizeCssPropertyName(value) {
250
+ const raw = firstString(value, "color");
251
+ return raw
252
+ .replace(/_/g, "-")
253
+ .replace(/[A-Z]/g, character => `-${character.toLowerCase()}`)
254
+ .toLowerCase();
255
+ }
256
+
257
+ function parseColor(value) {
258
+ const normalized = String(value || "").trim().toLowerCase();
259
+ if (!normalized) {
260
+ return null;
261
+ }
262
+ if (normalized === "transparent") {
263
+ return { r: 0, g: 0, b: 0, a: 0 };
264
+ }
265
+ const hexMatch = normalized.match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
266
+ if (hexMatch) {
267
+ let hexValue = hexMatch[1];
268
+ if (hexValue.length === 3 || hexValue.length === 4) {
269
+ hexValue = hexValue.split("").map(character => `${character}${character}`).join("");
270
+ }
271
+ return {
272
+ r: parseInt(hexValue.slice(0, 2), 16),
273
+ g: parseInt(hexValue.slice(2, 4), 16),
274
+ b: parseInt(hexValue.slice(4, 6), 16),
275
+ a: hexValue.length === 8 ? parseInt(hexValue.slice(6, 8), 16) / 255 : 1,
276
+ };
277
+ }
278
+ const rgbMatch = normalized.match(/^rgba?\(([^)]+)\)$/);
279
+ if (!rgbMatch) {
280
+ return null;
281
+ }
282
+ const parts = rgbMatch[1].split(",").map(part => part.trim());
283
+ if (parts.length < 3) {
284
+ return null;
285
+ }
286
+ const channels = parts.slice(0, 3).map(part => Number.parseFloat(part));
287
+ if (channels.some(channel => !Number.isFinite(channel))) {
288
+ return null;
289
+ }
290
+ const alpha = parts[3] !== undefined ? Number.parseFloat(parts[3]) : 1;
291
+ return {
292
+ r: clampByte(channels[0]),
293
+ g: clampByte(channels[1]),
294
+ b: clampByte(channels[2]),
295
+ a: Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1,
296
+ };
297
+ }
298
+
299
+ function formatColor(color) {
300
+ if (!color) {
301
+ return "(unresolved)";
302
+ }
303
+ return `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a.toFixed(2)})`;
304
+ }
305
+
306
+ function blendColor(foreground, background) {
307
+ const alpha = Number.isFinite(foreground?.a) ? Math.max(0, Math.min(1, foreground.a)) : 1;
308
+ return {
309
+ r: clampByte((foreground?.r || 0) * alpha + (background?.r || 0) * (1 - alpha)),
310
+ g: clampByte((foreground?.g || 0) * alpha + (background?.g || 0) * (1 - alpha)),
311
+ b: clampByte((foreground?.b || 0) * alpha + (background?.b || 0) * (1 - alpha)),
312
+ a: 1,
313
+ };
314
+ }
315
+
316
+ function relativeLuminance(channel) {
317
+ const normalized = channel / 255;
318
+ return normalized <= 0.03928 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4);
319
+ }
320
+
321
+ function getContrastRatio(foreground, background) {
322
+ const effectiveBackground = background?.a !== undefined && background.a < 1
323
+ ? blendColor(background, { r: 255, g: 255, b: 255, a: 1 })
324
+ : { ...(background || { r: 255, g: 255, b: 255, a: 1 }), a: 1 };
325
+ const effectiveForeground = foreground?.a !== undefined && foreground.a < 1
326
+ ? blendColor(foreground, effectiveBackground)
327
+ : { ...(foreground || { r: 0, g: 0, b: 0, a: 1 }), a: 1 };
328
+ const foregroundLuminance = (0.2126 * relativeLuminance(effectiveForeground.r)) +
329
+ (0.7152 * relativeLuminance(effectiveForeground.g)) +
330
+ (0.0722 * relativeLuminance(effectiveForeground.b));
331
+ const backgroundLuminance = (0.2126 * relativeLuminance(effectiveBackground.r)) +
332
+ (0.7152 * relativeLuminance(effectiveBackground.g)) +
333
+ (0.0722 * relativeLuminance(effectiveBackground.b));
334
+ const lighter = Math.max(foregroundLuminance, backgroundLuminance);
335
+ const darker = Math.min(foregroundLuminance, backgroundLuminance);
336
+ return (lighter + 0.05) / (darker + 0.05);
337
+ }
338
+
339
+ function getColorDistance(left, right) {
340
+ const alphaLeft = Number.isFinite(left?.a) ? left.a * 255 : 255;
341
+ const alphaRight = Number.isFinite(right?.a) ? right.a * 255 : 255;
342
+ return Math.sqrt(Math.pow((left?.r || 0) - (right?.r || 0), 2) +
343
+ Math.pow((left?.g || 0) - (right?.g || 0), 2) +
344
+ Math.pow((left?.b || 0) - (right?.b || 0), 2) +
345
+ Math.pow(alphaLeft - alphaRight, 2));
346
+ }
347
+
348
+ function getRouteViewports(routeConfig, defaultsConfig) {
349
+ const rawViewports = Array.isArray(routeConfig?.viewports) && routeConfig.viewports.length > 0
350
+ ? routeConfig.viewports
351
+ : Array.isArray(defaultsConfig?.viewports) && defaultsConfig.viewports.length > 0
352
+ ? defaultsConfig.viewports
353
+ : routeConfig?.viewport
354
+ ? [routeConfig.viewport]
355
+ : defaultsConfig?.viewport
356
+ ? [defaultsConfig.viewport]
357
+ : ["desktop"];
358
+ const seen = new Set();
359
+ const resolved = [];
360
+ for (const rawViewport of rawViewports) {
361
+ const viewport = getViewportConfig(rawViewport);
362
+ const key = `${viewport.name}:${viewport.width}x${viewport.height}`;
363
+ if (!seen.has(key)) {
364
+ seen.add(key);
365
+ resolved.push(viewport);
366
+ }
367
+ }
368
+ return resolved;
369
+ }
370
+
371
+ function resolveViewportBaselinePath(routeConfig, defaultsConfig, viewportName, workspaceRoot, projectPath) {
372
+ const pickBaseline = (value) => {
373
+ if (typeof value === "string") {
374
+ return value;
375
+ }
376
+ if (!isObject(value)) {
377
+ return "";
378
+ }
379
+ return firstString(value[viewportName], value.default, value.desktop);
380
+ };
381
+ const baselineRef = firstString(pickBaseline(routeConfig?.baseline), pickBaseline(defaultsConfig?.baseline));
382
+ return baselineRef ? resolveMaybePath(baselineRef, workspaceRoot, projectPath) : "";
383
+ }
384
+
385
+ function getRouteRequirements(routeConfig, defaultsConfig) {
386
+ return [
387
+ ...readStringList(defaultsConfig?.requirements),
388
+ ...readStringList(routeConfig?.requirements),
389
+ ];
390
+ }
391
+
392
+ function getRouteIgnoreSelectors(routeConfig, defaultsConfig) {
393
+ return Array.from(new Set([
394
+ ...readStringList(defaultsConfig?.ignore_selectors),
395
+ ...readStringList(routeConfig?.ignore_selectors),
396
+ ]));
397
+ }
398
+
399
+ function getRequiredVisibleSelectors(routeConfig, defaultsConfig) {
400
+ return Array.from(new Set([
401
+ ...readStringList(defaultsConfig?.required_visible),
402
+ ...readStringList(defaultsConfig?.selectors?.required_visible),
403
+ ...readStringList(routeConfig?.required_visible),
404
+ ...readStringList(routeConfig?.selectors?.required_visible),
405
+ ]));
406
+ }
407
+
408
+ function normalizeNoOverlapRule(value, fallbackName) {
409
+ if (Array.isArray(value) && value.length >= 2) {
410
+ const first = firstString(value[0]);
411
+ const second = firstString(value[1]);
412
+ if (first && second) {
413
+ return {
414
+ name: sanitizeName(fallbackName, fallbackName),
415
+ first,
416
+ second,
417
+ };
418
+ }
419
+ return null;
420
+ }
421
+ if (!isObject(value)) {
422
+ return null;
423
+ }
424
+ const first = firstString(value.first, value.left, value.primary);
425
+ const second = firstString(value.second, value.right, value.secondary);
426
+ if (!first || !second) {
427
+ return null;
428
+ }
429
+ return {
430
+ name: sanitizeName(firstString(value.name, fallbackName), fallbackName),
431
+ first,
432
+ second,
433
+ };
434
+ }
435
+
436
+ function getNoOverlapRules(routeConfig, defaultsConfig) {
437
+ const rawRules = [
438
+ ...((Array.isArray(defaultsConfig?.no_overlap) ? defaultsConfig.no_overlap : [])),
439
+ ...((Array.isArray(defaultsConfig?.selectors?.no_overlap) ? defaultsConfig.selectors.no_overlap : [])),
440
+ ...((Array.isArray(routeConfig?.no_overlap) ? routeConfig.no_overlap : [])),
441
+ ...((Array.isArray(routeConfig?.selectors?.no_overlap) ? routeConfig.selectors.no_overlap : [])),
442
+ ];
443
+ return rawRules
444
+ .map((rule, index) => normalizeNoOverlapRule(rule, `no-overlap-${index + 1}`))
445
+ .filter(Boolean);
446
+ }
447
+
448
+ function normalizeTypographyRule(value, fallbackName) {
449
+ if (!isObject(value)) {
450
+ return null;
451
+ }
452
+ const selector = firstString(value.selector);
453
+ if (!selector) {
454
+ return null;
455
+ }
456
+ const maxLines = toOptionalNumber(value.max_lines);
457
+ return {
458
+ name: sanitizeName(firstString(value.name, fallbackName), fallbackName),
459
+ selector,
460
+ font_family_includes: readStringList(value.font_family_includes || value.font_families || value.expected_fonts),
461
+ font_size_min: toOptionalNumber(value.font_size_min),
462
+ font_size_max: toOptionalNumber(value.font_size_max),
463
+ font_weight_min: toOptionalNumber(value.font_weight_min),
464
+ font_weight_max: toOptionalNumber(value.font_weight_max),
465
+ line_height_min: toOptionalNumber(value.line_height_min),
466
+ line_height_max: toOptionalNumber(value.line_height_max),
467
+ single_line: value.single_line === true || value.no_wrap === true,
468
+ max_lines: Number.isFinite(maxLines) && maxLines > 0 ? Math.floor(maxLines) : null,
469
+ };
470
+ }
471
+
472
+ function getTypographyRules(routeConfig, defaultsConfig) {
473
+ const rawRules = [
474
+ ...((Array.isArray(defaultsConfig?.typography) ? defaultsConfig.typography : [])),
475
+ ...((Array.isArray(routeConfig?.typography) ? routeConfig.typography : [])),
476
+ ];
477
+ return rawRules
478
+ .map((rule, index) => normalizeTypographyRule(rule, `typography-${index + 1}`))
479
+ .filter(Boolean);
480
+ }
481
+
482
+ function normalizeColorRule(value, fallbackName) {
483
+ if (!isObject(value)) {
484
+ return null;
485
+ }
486
+ const selector = firstString(value.selector);
487
+ const expected = firstString(value.equals, value.expected, value.value);
488
+ if (!selector || !expected) {
489
+ return null;
490
+ }
491
+ const tolerance = toOptionalNumber(value.tolerance);
492
+ return {
493
+ name: sanitizeName(firstString(value.name, fallbackName), fallbackName),
494
+ selector,
495
+ property: normalizeCssPropertyName(value.property),
496
+ expected,
497
+ tolerance: Number.isFinite(tolerance) && tolerance >= 0 ? tolerance : 0,
498
+ };
499
+ }
500
+
501
+ function getColorRules(routeConfig, defaultsConfig) {
502
+ const rawRules = [
503
+ ...((Array.isArray(defaultsConfig?.colors) ? defaultsConfig.colors : [])),
504
+ ...((Array.isArray(routeConfig?.colors) ? routeConfig.colors : [])),
505
+ ];
506
+ return rawRules
507
+ .map((rule, index) => normalizeColorRule(rule, `color-${index + 1}`))
508
+ .filter(Boolean);
509
+ }
510
+
511
+ function normalizeContrastRule(value, fallbackName) {
512
+ if (typeof value === "string") {
513
+ return {
514
+ name: sanitizeName(fallbackName, fallbackName),
515
+ selectors: [value.trim()],
516
+ min_ratio: 4.5,
517
+ max_issues: 8,
518
+ };
519
+ }
520
+ if (!isObject(value)) {
521
+ return null;
522
+ }
523
+ if (value.enabled === false) {
524
+ return null;
525
+ }
526
+ const selectors = Array.from(new Set([
527
+ ...readStringList(value.selector),
528
+ ...readStringList(value.selectors),
529
+ ]));
530
+ const minRatio = toOptionalNumber(value.min_ratio);
531
+ const maxIssues = toOptionalNumber(value.max_issues);
532
+ return {
533
+ name: sanitizeName(firstString(value.name, fallbackName), fallbackName),
534
+ selectors: selectors.length > 0
535
+ ? selectors
536
+ : ["h1", "h2", "h3", "h4", "p", "a", "button", "label", "input", "textarea", "li"],
537
+ min_ratio: Number.isFinite(minRatio) && minRatio > 0 ? minRatio : 4.5,
538
+ max_issues: Number.isFinite(maxIssues) && maxIssues > 0 ? Math.floor(maxIssues) : 8,
539
+ };
540
+ }
541
+
542
+ function getContrastRules(routeConfig, defaultsConfig) {
543
+ if (routeConfig?.contrast === false) {
544
+ return [];
545
+ }
546
+ const rawRules = [];
547
+ if (defaultsConfig?.contrast !== false) {
548
+ rawRules.push(...(Array.isArray(defaultsConfig?.contrast) ? defaultsConfig.contrast : defaultsConfig?.contrast ? [defaultsConfig.contrast] : []));
549
+ }
550
+ if (routeConfig?.contrast !== undefined) {
551
+ rawRules.push(...(Array.isArray(routeConfig.contrast) ? routeConfig.contrast : routeConfig.contrast ? [routeConfig.contrast] : []));
552
+ }
553
+ if (rawRules.length === 0) {
554
+ rawRules.push({
555
+ name: "default-text-contrast",
556
+ selectors: ["h1", "h2", "h3", "h4", "p", "a", "button", "label", "input", "textarea", "li"],
557
+ min_ratio: 4.5,
558
+ max_issues: 8,
559
+ });
560
+ }
561
+ return rawRules
562
+ .map((rule, index) => normalizeContrastRule(rule, `contrast-${index + 1}`))
563
+ .filter(Boolean);
564
+ }
565
+
566
+ function resolveTemplateValue(value, context) {
567
+ return String(value || "")
568
+ .replace(/\{change_path\}|\$\{change_path\}/g, context.change_path)
569
+ .replace(/\{project_path\}|\$\{project_path\}/g, context.project_path)
570
+ .replace(/\{ospec_package_path\}|\$\{ospec_package_path\}/g, context.ospec_package_path)
571
+ .replace(/\{base_url\}|\$\{base_url\}/g, String(context.base_url || ""))
572
+ .replace(/\{storage_state_path\}|\$\{storage_state_path\}/g, String(context.storage_state_path || ""));
573
+ }
574
+
575
+ function resolveMaybePath(filePath, cwd, projectPath) {
576
+ const normalized = String(filePath || "").trim();
577
+ if (!normalized) {
578
+ return "";
579
+ }
580
+ if (path.isAbsolute(normalized)) {
581
+ return normalized;
582
+ }
583
+ if (normalized.startsWith(".") || normalized.includes("/") || normalized.includes("\\")) {
584
+ return path.resolve(cwd, normalized);
585
+ }
586
+ return path.resolve(projectPath, normalized);
587
+ }
588
+
589
+ function joinUrl(baseUrl, nextPath) {
590
+ const normalizedBase = String(baseUrl || "").trim();
591
+ const normalizedPath = String(nextPath || "").trim();
592
+ if (!normalizedBase) {
593
+ return normalizedPath;
594
+ }
595
+ return new URL(normalizedPath || "/", normalizedBase.endsWith("/") ? normalizedBase : `${normalizedBase}/`).toString();
596
+ }
597
+
598
+ function getViewportConfig(viewport) {
599
+ const raw = typeof viewport === "string" ? viewport.trim().toLowerCase() : "";
600
+ if (viewport && typeof viewport === "object" && !Array.isArray(viewport)) {
601
+ const width = Number.isFinite(viewport.width) ? Math.max(320, Math.floor(viewport.width)) : 1440;
602
+ const height = Number.isFinite(viewport.height) ? Math.max(320, Math.floor(viewport.height)) : 960;
603
+ return {
604
+ name: sanitizeName(viewport.name || `${width}x${height}`, "custom"),
605
+ width,
606
+ height,
607
+ };
608
+ }
609
+ if (raw === "mobile") {
610
+ return { name: "mobile", width: 390, height: 844 };
611
+ }
612
+ if (raw === "tablet") {
613
+ return { name: "tablet", width: 1024, height: 1366 };
614
+ }
615
+ return { name: raw || "desktop", width: 1440, height: 960 };
616
+ }
617
+
618
+ function resolveModule(projectPath, moduleName) {
619
+ const candidates = [
620
+ path.join(projectPath, "package.json"),
621
+ path.join(path.resolve(__dirname, "..", ".."), "package.json"),
622
+ path.join(projectPath, "index.js"),
623
+ path.join(path.resolve(__dirname, "..", ".."), "index.js"),
624
+ ];
625
+ for (const candidate of candidates) {
626
+ try {
627
+ const scopedRequire = createRequire(candidate);
628
+ return scopedRequire(moduleName);
629
+ }
630
+ catch {
631
+ }
632
+ }
633
+ return null;
634
+ }
635
+
636
+ async function loadJson(filePath) {
637
+ return JSON.parse((await fs.readFile(filePath, "utf8")).replace(/^\uFEFF/, ""));
638
+ }
639
+
640
+ async function readYamlIfExists(filePath) {
641
+ if (!(await fs.pathExists(filePath))) {
642
+ return null;
643
+ }
644
+ return yaml.load(await fs.readFile(filePath, "utf8"));
645
+ }
646
+
647
+ async function startRuntime(startupConfig, context, traceLogPath) {
648
+ const command = firstString(startupConfig?.command);
649
+ if (!command) {
650
+ return {
651
+ started: false,
652
+ child: null,
653
+ logPath: "",
654
+ };
655
+ }
656
+ const args = Array.isArray(startupConfig?.args) ? startupConfig.args.map(value => resolveTemplateValue(String(value), context)) : [];
657
+ const cwdValue = firstString(startupConfig?.cwd, "${project_path}");
658
+ const cwd = resolveMaybePath(resolveTemplateValue(cwdValue, context), context.project_path, context.project_path);
659
+ await fs.ensureDir(path.dirname(traceLogPath));
660
+ const logStream = fs.createWriteStream(traceLogPath, { flags: "a" });
661
+ const child = spawn(command, args, {
662
+ cwd,
663
+ env: process.env,
664
+ shell: false,
665
+ stdio: ["ignore", "pipe", "pipe"],
666
+ });
667
+ child.stdout?.pipe(logStream, { end: false });
668
+ child.stderr?.pipe(logStream, { end: false });
669
+ await new Promise((resolve, reject) => {
670
+ let settled = false;
671
+ child.once("spawn", () => {
672
+ settled = true;
673
+ resolve();
674
+ });
675
+ child.once("error", error => {
676
+ if (!settled) {
677
+ reject(error);
678
+ }
679
+ });
680
+ setTimeout(() => {
681
+ if (!settled) {
682
+ resolve();
683
+ }
684
+ }, 250);
685
+ });
686
+ return {
687
+ started: true,
688
+ child,
689
+ logPath: traceLogPath,
690
+ logStream,
691
+ };
692
+ }
693
+
694
+ async function stopRuntime(shutdownConfig, startupState, context) {
695
+ const command = firstString(shutdownConfig?.command);
696
+ if (command) {
697
+ const args = Array.isArray(shutdownConfig?.args) ? shutdownConfig.args.map(value => resolveTemplateValue(String(value), context)) : [];
698
+ const cwdValue = firstString(shutdownConfig?.cwd, "${project_path}");
699
+ const cwd = resolveMaybePath(resolveTemplateValue(cwdValue, context), context.project_path, context.project_path);
700
+ const result = spawnSync(command, args, {
701
+ cwd,
702
+ env: process.env,
703
+ encoding: "utf-8",
704
+ shell: false,
705
+ timeout: Number.isFinite(shutdownConfig?.timeout_ms) && shutdownConfig.timeout_ms > 0 ? Math.floor(shutdownConfig.timeout_ms) : 120000,
706
+ });
707
+ startupState.logStream?.end();
708
+ if (result.error) {
709
+ throw result.error;
710
+ }
711
+ return;
712
+ }
713
+ if (startupState?.child && startupState.child.exitCode === null && !startupState.child.killed) {
714
+ if (process.platform === "win32") {
715
+ spawnSync("taskkill", ["/pid", String(startupState.child.pid), "/t", "/f"], {
716
+ encoding: "utf-8",
717
+ shell: false,
718
+ });
719
+ }
720
+ else {
721
+ try {
722
+ process.kill(-startupState.child.pid, "SIGTERM");
723
+ }
724
+ catch {
725
+ try {
726
+ startupState.child.kill("SIGTERM");
727
+ }
728
+ catch {
729
+ }
730
+ }
731
+ }
732
+ }
733
+ startupState.logStream?.end();
734
+ }
735
+
736
+ async function waitForReadiness(readinessConfig, baseUrl, startupState) {
737
+ const type = firstString(readinessConfig?.type, "url").toLowerCase();
738
+ if (type !== "url") {
739
+ return {
740
+ ok: false,
741
+ message: `Unsupported readiness type: ${type || "(empty)"}`,
742
+ };
743
+ }
744
+ const readinessUrl = firstString(readinessConfig?.url, baseUrl);
745
+ if (!readinessUrl) {
746
+ return {
747
+ ok: false,
748
+ message: "No readiness URL is configured and runtime.base_url is empty.",
749
+ };
750
+ }
751
+ const timeoutMs = Number.isFinite(readinessConfig?.timeout_ms) && readinessConfig.timeout_ms > 0
752
+ ? Math.floor(readinessConfig.timeout_ms)
753
+ : 180000;
754
+ const startedAt = Date.now();
755
+ let lastError = "";
756
+ while (Date.now() - startedAt < timeoutMs) {
757
+ if (startupState?.child && startupState.child.exitCode !== null && startupState.child.exitCode !== 0) {
758
+ return {
759
+ ok: false,
760
+ message: `Startup command exited before readiness succeeded with code ${startupState.child.exitCode}`,
761
+ };
762
+ }
763
+ try {
764
+ const response = await fetch(readinessUrl, {
765
+ method: "GET",
766
+ redirect: "follow",
767
+ });
768
+ if (response.status < 500) {
769
+ return {
770
+ ok: true,
771
+ message: `Readiness probe succeeded with status ${response.status}`,
772
+ };
773
+ }
774
+ lastError = `HTTP ${response.status}`;
775
+ }
776
+ catch (error) {
777
+ lastError = error.message;
778
+ }
779
+ await new Promise(resolve => setTimeout(resolve, 1500));
780
+ }
781
+ return {
782
+ ok: false,
783
+ message: `Readiness probe timed out after ${timeoutMs}ms (${lastError || "no successful response"})`,
784
+ };
785
+ }
786
+
787
+ async function runAuthCommand(authConfig, context, storageStatePath, traceLogPath) {
788
+ const command = firstString(authConfig?.command);
789
+ if (!command) {
790
+ return {
791
+ ran: false,
792
+ logPath: "",
793
+ message: "No auth command is configured.",
794
+ };
795
+ }
796
+ const when = firstString(authConfig?.when, "missing_storage_state").toLowerCase();
797
+ const storageStateExists = storageStatePath ? await fs.pathExists(storageStatePath) : false;
798
+ if (when !== "always" && storageStateExists) {
799
+ return {
800
+ ran: false,
801
+ logPath: "",
802
+ message: "Auth command was skipped because storage_state already exists.",
803
+ };
804
+ }
805
+ const args = Array.isArray(authConfig?.args) ? authConfig.args.map(value => resolveTemplateValue(String(value), context)) : [];
806
+ const cwdValue = firstString(authConfig?.cwd, "${project_path}");
807
+ const cwd = resolveMaybePath(resolveTemplateValue(cwdValue, context), context.project_path, context.project_path);
808
+ const timeoutMs = Number.isFinite(authConfig?.timeout_ms) && authConfig.timeout_ms > 0 ? Math.floor(authConfig.timeout_ms) : 300000;
809
+ await fs.ensureDir(path.dirname(traceLogPath));
810
+ const result = spawnSync(command, args, {
811
+ cwd,
812
+ env: {
813
+ ...process.env,
814
+ OSPEC_CHECKPOINT_BASE_URL: String(context.base_url || ""),
815
+ OSPEC_CHECKPOINT_PROJECT_PATH: context.project_path,
816
+ OSPEC_CHECKPOINT_CHANGE_PATH: context.change_path,
817
+ OSPEC_CHECKPOINT_STORAGE_STATE: String(storageStatePath || ""),
818
+ OSPEC_CHECKPOINT_AUTH_DIR: storageStatePath ? path.dirname(storageStatePath) : path.join(context.project_path, ".ospec", "plugins", "checkpoint", "auth"),
819
+ OSPEC_CHECKPOINT_OSPEC_PACKAGE_PATH: context.ospec_package_path,
820
+ },
821
+ encoding: "utf-8",
822
+ shell: false,
823
+ timeout: timeoutMs,
824
+ });
825
+ const combinedOutput = [String(result.stdout || "").trim(), String(result.stderr || "").trim()]
826
+ .filter(Boolean)
827
+ .join("\n");
828
+ if (combinedOutput) {
829
+ await fs.writeFile(traceLogPath, `${combinedOutput}\n`);
830
+ }
831
+ else {
832
+ await fs.writeFile(traceLogPath, "");
833
+ }
834
+ if (result.error) {
835
+ throw result.error;
836
+ }
837
+ if (result.status !== 0) {
838
+ throw new Error(`Auth command exited with status ${result.status}: ${combinedOutput || "no output"}`);
839
+ }
840
+ return {
841
+ ran: true,
842
+ logPath: traceLogPath,
843
+ message: "Auth command completed successfully.",
844
+ };
845
+ }
846
+
847
+ async function collectLayoutSignals(page) {
848
+ return page.evaluate(() => {
849
+ const describe = (element) => {
850
+ const parts = [element.tagName.toLowerCase()];
851
+ if (element.id) {
852
+ parts.push(`#${element.id}`);
853
+ }
854
+ if (element.classList && element.classList.length > 0) {
855
+ parts.push(`.${Array.from(element.classList).slice(0, 2).join(".")}`);
856
+ }
857
+ return parts.join("");
858
+ };
859
+ const nodes = Array.from(document.querySelectorAll("body *"));
860
+ const clippedText = [];
861
+ const coveredElements = [];
862
+ for (const element of nodes) {
863
+ const text = (element.textContent || "").trim();
864
+ const style = window.getComputedStyle(element);
865
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) {
866
+ continue;
867
+ }
868
+ const rect = element.getBoundingClientRect();
869
+ if (rect.width <= 0 || rect.height <= 0) {
870
+ continue;
871
+ }
872
+ if (text) {
873
+ const horizontalClip = element.scrollWidth - element.clientWidth > 4 &&
874
+ (style.overflowX === "hidden" || style.overflowX === "clip" || style.textOverflow === "ellipsis");
875
+ const verticalClip = element.scrollHeight - element.clientHeight > 4 &&
876
+ (style.overflowY === "hidden" || style.overflowY === "clip" || style.webkitLineClamp !== "none");
877
+ if (horizontalClip || verticalClip) {
878
+ clippedText.push({
879
+ selector: describe(element),
880
+ text: text.slice(0, 120),
881
+ });
882
+ }
883
+ if (clippedText.length >= 8) {
884
+ break;
885
+ }
886
+ }
887
+ if (coveredElements.length < 6 && rect.width >= 24 && rect.height >= 16 && (text || /^(button|a|input|textarea|select)$/i.test(element.tagName))) {
888
+ const centerX = Math.max(0, Math.min(window.innerWidth - 1, rect.left + (rect.width / 2)));
889
+ const centerY = Math.max(0, Math.min(window.innerHeight - 1, rect.top + (rect.height / 2)));
890
+ const topElement = document.elementFromPoint(centerX, centerY);
891
+ if (topElement &&
892
+ topElement !== element &&
893
+ !element.contains(topElement) &&
894
+ !topElement.contains(element)) {
895
+ coveredElements.push({
896
+ selector: describe(element),
897
+ coveredBy: describe(topElement),
898
+ text: text.slice(0, 120),
899
+ });
900
+ }
901
+ }
902
+ }
903
+ const doc = document.documentElement;
904
+ return {
905
+ horizontalOverflow: doc.scrollWidth - doc.clientWidth > 1,
906
+ clippedText,
907
+ coveredElements,
908
+ };
909
+ });
910
+ }
911
+
912
+ async function applyIgnoredSelectors(page, selectors) {
913
+ if (!Array.isArray(selectors) || selectors.length === 0) {
914
+ return;
915
+ }
916
+ await page.evaluate((selectorList) => {
917
+ for (const selector of selectorList) {
918
+ try {
919
+ document.querySelectorAll(selector).forEach(element => {
920
+ element.setAttribute("data-ospec-checkpoint-ignored", "true");
921
+ element.style.setProperty("visibility", "hidden", "important");
922
+ element.style.setProperty("opacity", "0", "important");
923
+ element.style.setProperty("pointer-events", "none", "important");
924
+ });
925
+ }
926
+ catch {
927
+ }
928
+ }
929
+ }, selectors);
930
+ }
931
+
932
+ async function runVisibilityChecks(page, selectors, routeName, viewportName) {
933
+ const issues = [];
934
+ for (const selector of selectors) {
935
+ const inspection = await page.evaluate((targetSelector) => {
936
+ const describe = (element) => {
937
+ const parts = [element.tagName.toLowerCase()];
938
+ if (element.id) {
939
+ parts.push(`#${element.id}`);
940
+ }
941
+ if (element.classList && element.classList.length > 0) {
942
+ parts.push(`.${Array.from(element.classList).slice(0, 2).join(".")}`);
943
+ }
944
+ return parts.join("");
945
+ };
946
+ const element = document.querySelector(targetSelector);
947
+ if (!element) {
948
+ return { found: false };
949
+ }
950
+ const style = window.getComputedStyle(element);
951
+ const rect = element.getBoundingClientRect();
952
+ const visible = style.visibility !== "hidden" &&
953
+ style.display !== "none" &&
954
+ Number(style.opacity || "1") > 0 &&
955
+ rect.width > 0 &&
956
+ rect.height > 0;
957
+ const inViewport = rect.bottom > 0 &&
958
+ rect.right > 0 &&
959
+ rect.top < window.innerHeight &&
960
+ rect.left < window.innerWidth;
961
+ return {
962
+ found: true,
963
+ visible,
964
+ inViewport,
965
+ resolvedSelector: describe(element),
966
+ text: (element.textContent || element.getAttribute("aria-label") || "").trim().replace(/\s+/g, " ").slice(0, 120),
967
+ };
968
+ }, selector);
969
+ if (!inspection.found) {
970
+ issues.push(createRouteIssue(routeName, viewportName, `Required selector "${selector}" was not found on ${routeName} [${viewportName}].`, { code: "required_selector_missing" }));
971
+ continue;
972
+ }
973
+ if (!inspection.visible) {
974
+ issues.push(createRouteIssue(routeName, viewportName, `Required selector "${selector}" is hidden on ${routeName} [${viewportName}].`, {
975
+ code: "required_selector_hidden",
976
+ selector: inspection.resolvedSelector,
977
+ }));
978
+ continue;
979
+ }
980
+ if (!inspection.inViewport) {
981
+ issues.push(createRouteIssue(routeName, viewportName, `Required selector "${selector}" is outside the current viewport on ${routeName} [${viewportName}].`, {
982
+ code: "required_selector_offscreen",
983
+ selector: inspection.resolvedSelector,
984
+ text: inspection.text,
985
+ }));
986
+ }
987
+ }
988
+ return issues;
989
+ }
990
+
991
+ async function runOverlapChecks(page, rules, routeName, viewportName) {
992
+ const issues = [];
993
+ for (const rule of rules) {
994
+ const inspection = await page.evaluate((input) => {
995
+ const describe = (element) => {
996
+ const parts = [element.tagName.toLowerCase()];
997
+ if (element.id) {
998
+ parts.push(`#${element.id}`);
999
+ }
1000
+ if (element.classList && element.classList.length > 0) {
1001
+ parts.push(`.${Array.from(element.classList).slice(0, 2).join(".")}`);
1002
+ }
1003
+ return parts.join("");
1004
+ };
1005
+ const left = document.querySelector(input.first);
1006
+ const right = document.querySelector(input.second);
1007
+ if (!left || !right) {
1008
+ return {
1009
+ foundLeft: Boolean(left),
1010
+ foundRight: Boolean(right),
1011
+ };
1012
+ }
1013
+ const leftStyle = window.getComputedStyle(left);
1014
+ const rightStyle = window.getComputedStyle(right);
1015
+ const leftRect = left.getBoundingClientRect();
1016
+ const rightRect = right.getBoundingClientRect();
1017
+ const leftVisible = leftStyle.visibility !== "hidden" && leftStyle.display !== "none" && Number(leftStyle.opacity || "1") > 0 && leftRect.width > 0 && leftRect.height > 0;
1018
+ const rightVisible = rightStyle.visibility !== "hidden" && rightStyle.display !== "none" && Number(rightStyle.opacity || "1") > 0 && rightRect.width > 0 && rightRect.height > 0;
1019
+ const overlapWidth = Math.max(0, Math.min(leftRect.right, rightRect.right) - Math.max(leftRect.left, rightRect.left));
1020
+ const overlapHeight = Math.max(0, Math.min(leftRect.bottom, rightRect.bottom) - Math.max(leftRect.top, rightRect.top));
1021
+ return {
1022
+ foundLeft: true,
1023
+ foundRight: true,
1024
+ leftVisible,
1025
+ rightVisible,
1026
+ leftSelector: describe(left),
1027
+ rightSelector: describe(right),
1028
+ overlapArea: overlapWidth * overlapHeight,
1029
+ };
1030
+ }, rule);
1031
+ if (!inspection.foundLeft || !inspection.foundRight) {
1032
+ issues.push(createRouteIssue(routeName, viewportName, `No-overlap rule "${rule.name}" could not resolve both selectors on ${routeName} [${viewportName}].`, {
1033
+ code: "overlap_selector_missing",
1034
+ rule: rule.name,
1035
+ first: rule.first,
1036
+ second: rule.second,
1037
+ }));
1038
+ continue;
1039
+ }
1040
+ if (!inspection.leftVisible || !inspection.rightVisible) {
1041
+ issues.push(createRouteIssue(routeName, viewportName, `No-overlap rule "${rule.name}" matched a hidden element on ${routeName} [${viewportName}].`, {
1042
+ code: "overlap_selector_hidden",
1043
+ first: inspection.leftSelector,
1044
+ second: inspection.rightSelector,
1045
+ }));
1046
+ continue;
1047
+ }
1048
+ if ((inspection.overlapArea || 0) > 4) {
1049
+ issues.push(createRouteIssue(routeName, viewportName, `Selectors "${rule.first}" and "${rule.second}" overlap on ${routeName} [${viewportName}].`, {
1050
+ code: "element_overlap",
1051
+ rule: rule.name,
1052
+ first: inspection.leftSelector,
1053
+ second: inspection.rightSelector,
1054
+ overlap_area: inspection.overlapArea,
1055
+ }));
1056
+ }
1057
+ }
1058
+ return issues;
1059
+ }
1060
+
1061
+ async function runTypographyChecks(page, rules, routeName, viewportName) {
1062
+ const issues = [];
1063
+ for (const rule of rules) {
1064
+ const inspection = await page.evaluate((input) => {
1065
+ const normalizeFontWeight = (value) => {
1066
+ if (value === "normal") {
1067
+ return 400;
1068
+ }
1069
+ if (value === "bold") {
1070
+ return 700;
1071
+ }
1072
+ const numeric = Number.parseFloat(value);
1073
+ return Number.isFinite(numeric) ? numeric : null;
1074
+ };
1075
+ const describe = (element) => {
1076
+ const parts = [element.tagName.toLowerCase()];
1077
+ if (element.id) {
1078
+ parts.push(`#${element.id}`);
1079
+ }
1080
+ if (element.classList && element.classList.length > 0) {
1081
+ parts.push(`.${Array.from(element.classList).slice(0, 2).join(".")}`);
1082
+ }
1083
+ return parts.join("");
1084
+ };
1085
+ const samples = Array.from(document.querySelectorAll(input.selector))
1086
+ .slice(0, 4)
1087
+ .map(element => {
1088
+ const style = window.getComputedStyle(element);
1089
+ const rect = element.getBoundingClientRect();
1090
+ const lineHeight = style.lineHeight === "normal" ? null : Number.parseFloat(style.lineHeight);
1091
+ const estimatedLines = Number.isFinite(lineHeight) && lineHeight > 0
1092
+ ? Math.max(1, Math.round(rect.height / lineHeight))
1093
+ : null;
1094
+ return {
1095
+ selector: describe(element),
1096
+ text: (element.textContent || "").trim().replace(/\s+/g, " ").slice(0, 120),
1097
+ visible: style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity || "1") > 0 && rect.width > 0 && rect.height > 0,
1098
+ fontFamily: style.fontFamily || "",
1099
+ fontSize: Number.parseFloat(style.fontSize),
1100
+ fontWeight: normalizeFontWeight(style.fontWeight),
1101
+ lineHeight,
1102
+ estimatedLines,
1103
+ wraps: element.scrollWidth - element.clientWidth > 2 || (Number.isFinite(estimatedLines) && estimatedLines > 1),
1104
+ };
1105
+ });
1106
+ return {
1107
+ count: samples.length,
1108
+ samples,
1109
+ };
1110
+ }, rule);
1111
+ if ((inspection.count || 0) === 0) {
1112
+ issues.push(createRouteIssue(routeName, viewportName, `Typography selector "${rule.selector}" was not found on ${routeName} [${viewportName}].`, {
1113
+ code: "typography_selector_missing",
1114
+ rule: rule.name,
1115
+ }));
1116
+ continue;
1117
+ }
1118
+ for (const sample of inspection.samples || []) {
1119
+ if (!sample.visible) {
1120
+ continue;
1121
+ }
1122
+ const actualFontFamily = String(sample.fontFamily || "").toLowerCase();
1123
+ if (rule.font_family_includes.length > 0 && !rule.font_family_includes.some(expectedFamily => actualFontFamily.includes(String(expectedFamily).toLowerCase()))) {
1124
+ issues.push(createRouteIssue(routeName, viewportName, `Typography rule "${rule.name}" expected ${rule.selector} to use ${rule.font_family_includes.join(", ")}, but ${sample.selector} resolved to "${sample.fontFamily}".`, {
1125
+ code: "font_family_mismatch",
1126
+ selector: sample.selector,
1127
+ rule: rule.name,
1128
+ text: sample.text,
1129
+ expected: rule.font_family_includes.join(", "),
1130
+ actual: sample.fontFamily,
1131
+ }));
1132
+ }
1133
+ if (Number.isFinite(rule.font_size_min) && Number.isFinite(sample.fontSize) && sample.fontSize < rule.font_size_min) {
1134
+ issues.push(createRouteIssue(routeName, viewportName, `Typography rule "${rule.name}" expected ${rule.selector} font-size >= ${rule.font_size_min}px, but ${sample.selector} resolved to ${sample.fontSize}px.`, {
1135
+ code: "font_size_mismatch",
1136
+ selector: sample.selector,
1137
+ rule: rule.name,
1138
+ text: sample.text,
1139
+ expected: `>= ${rule.font_size_min}px`,
1140
+ actual: `${sample.fontSize}px`,
1141
+ }));
1142
+ }
1143
+ if (Number.isFinite(rule.font_size_max) && Number.isFinite(sample.fontSize) && sample.fontSize > rule.font_size_max) {
1144
+ issues.push(createRouteIssue(routeName, viewportName, `Typography rule "${rule.name}" expected ${rule.selector} font-size <= ${rule.font_size_max}px, but ${sample.selector} resolved to ${sample.fontSize}px.`, {
1145
+ code: "font_size_mismatch",
1146
+ selector: sample.selector,
1147
+ rule: rule.name,
1148
+ text: sample.text,
1149
+ expected: `<= ${rule.font_size_max}px`,
1150
+ actual: `${sample.fontSize}px`,
1151
+ }));
1152
+ }
1153
+ if (Number.isFinite(rule.font_weight_min) && Number.isFinite(sample.fontWeight) && sample.fontWeight < rule.font_weight_min) {
1154
+ issues.push(createRouteIssue(routeName, viewportName, `Typography rule "${rule.name}" expected ${rule.selector} font-weight >= ${rule.font_weight_min}, but ${sample.selector} resolved to ${sample.fontWeight}.`, {
1155
+ code: "font_weight_mismatch",
1156
+ selector: sample.selector,
1157
+ rule: rule.name,
1158
+ text: sample.text,
1159
+ expected: `>= ${rule.font_weight_min}`,
1160
+ actual: String(sample.fontWeight),
1161
+ }));
1162
+ }
1163
+ if (Number.isFinite(rule.font_weight_max) && Number.isFinite(sample.fontWeight) && sample.fontWeight > rule.font_weight_max) {
1164
+ issues.push(createRouteIssue(routeName, viewportName, `Typography rule "${rule.name}" expected ${rule.selector} font-weight <= ${rule.font_weight_max}, but ${sample.selector} resolved to ${sample.fontWeight}.`, {
1165
+ code: "font_weight_mismatch",
1166
+ selector: sample.selector,
1167
+ rule: rule.name,
1168
+ text: sample.text,
1169
+ expected: `<= ${rule.font_weight_max}`,
1170
+ actual: String(sample.fontWeight),
1171
+ }));
1172
+ }
1173
+ if (Number.isFinite(rule.line_height_min) && Number.isFinite(sample.lineHeight) && sample.lineHeight < rule.line_height_min) {
1174
+ issues.push(createRouteIssue(routeName, viewportName, `Typography rule "${rule.name}" expected ${rule.selector} line-height >= ${rule.line_height_min}px, but ${sample.selector} resolved to ${sample.lineHeight}px.`, {
1175
+ code: "line_height_mismatch",
1176
+ selector: sample.selector,
1177
+ rule: rule.name,
1178
+ text: sample.text,
1179
+ expected: `>= ${rule.line_height_min}px`,
1180
+ actual: `${sample.lineHeight}px`,
1181
+ }));
1182
+ }
1183
+ if (Number.isFinite(rule.line_height_max) && Number.isFinite(sample.lineHeight) && sample.lineHeight > rule.line_height_max) {
1184
+ issues.push(createRouteIssue(routeName, viewportName, `Typography rule "${rule.name}" expected ${rule.selector} line-height <= ${rule.line_height_max}px, but ${sample.selector} resolved to ${sample.lineHeight}px.`, {
1185
+ code: "line_height_mismatch",
1186
+ selector: sample.selector,
1187
+ rule: rule.name,
1188
+ text: sample.text,
1189
+ expected: `<= ${rule.line_height_max}px`,
1190
+ actual: `${sample.lineHeight}px`,
1191
+ }));
1192
+ }
1193
+ if (rule.single_line && sample.wraps) {
1194
+ issues.push(createRouteIssue(routeName, viewportName, `Typography rule "${rule.name}" expected ${rule.selector} to remain on one line, but ${sample.selector} appears wrapped on ${routeName} [${viewportName}].`, {
1195
+ code: "text_wrap_failed",
1196
+ selector: sample.selector,
1197
+ rule: rule.name,
1198
+ text: sample.text,
1199
+ expected: "single line",
1200
+ estimated_lines: sample.estimatedLines,
1201
+ }));
1202
+ }
1203
+ if (Number.isFinite(rule.max_lines) && Number.isFinite(sample.estimatedLines) && sample.estimatedLines > rule.max_lines) {
1204
+ issues.push(createRouteIssue(routeName, viewportName, `Typography rule "${rule.name}" expected ${rule.selector} to stay within ${rule.max_lines} lines, but ${sample.selector} appears to use ${sample.estimatedLines} lines.`, {
1205
+ code: "text_wrap_failed",
1206
+ selector: sample.selector,
1207
+ rule: rule.name,
1208
+ text: sample.text,
1209
+ expected: `<= ${rule.max_lines} lines`,
1210
+ actual: `${sample.estimatedLines} lines`,
1211
+ estimated_lines: sample.estimatedLines,
1212
+ }));
1213
+ }
1214
+ }
1215
+ }
1216
+ return issues;
1217
+ }
1218
+
1219
+ async function runColorChecks(page, rules, routeName, viewportName) {
1220
+ const issues = [];
1221
+ for (const rule of rules) {
1222
+ const expectedColor = parseColor(rule.expected);
1223
+ if (!expectedColor) {
1224
+ issues.push(createRouteIssue(routeName, viewportName, `Color rule "${rule.name}" uses an unsupported expected color "${rule.expected}".`, {
1225
+ code: "color_config_invalid",
1226
+ rule: rule.name,
1227
+ expected: rule.expected,
1228
+ }));
1229
+ continue;
1230
+ }
1231
+ const inspection = await page.evaluate((input) => {
1232
+ const describe = (element) => {
1233
+ const parts = [element.tagName.toLowerCase()];
1234
+ if (element.id) {
1235
+ parts.push(`#${element.id}`);
1236
+ }
1237
+ if (element.classList && element.classList.length > 0) {
1238
+ parts.push(`.${Array.from(element.classList).slice(0, 2).join(".")}`);
1239
+ }
1240
+ return parts.join("");
1241
+ };
1242
+ const samples = Array.from(document.querySelectorAll(input.selector))
1243
+ .slice(0, 4)
1244
+ .map(element => {
1245
+ const style = window.getComputedStyle(element);
1246
+ const rect = element.getBoundingClientRect();
1247
+ const cssValue = style.getPropertyValue(input.property) || style[input.camelProperty] || "";
1248
+ return {
1249
+ selector: describe(element),
1250
+ text: (element.textContent || element.getAttribute("aria-label") || "").trim().replace(/\s+/g, " ").slice(0, 120),
1251
+ visible: style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity || "1") > 0 && rect.width > 0 && rect.height > 0,
1252
+ value: String(cssValue || "").trim(),
1253
+ };
1254
+ });
1255
+ return {
1256
+ count: samples.length,
1257
+ samples,
1258
+ };
1259
+ }, {
1260
+ selector: rule.selector,
1261
+ property: rule.property,
1262
+ camelProperty: rule.property.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()),
1263
+ });
1264
+ if ((inspection.count || 0) === 0) {
1265
+ issues.push(createRouteIssue(routeName, viewportName, `Color selector "${rule.selector}" was not found on ${routeName} [${viewportName}].`, {
1266
+ code: "color_selector_missing",
1267
+ rule: rule.name,
1268
+ selector: rule.selector,
1269
+ }));
1270
+ continue;
1271
+ }
1272
+ for (const sample of inspection.samples || []) {
1273
+ if (!sample.visible) {
1274
+ continue;
1275
+ }
1276
+ const actualColor = parseColor(sample.value);
1277
+ if (!actualColor) {
1278
+ issues.push(createRouteIssue(routeName, viewportName, `Color rule "${rule.name}" could not resolve ${rule.property} for ${sample.selector} on ${routeName} [${viewportName}].`, {
1279
+ code: "color_value_unresolved",
1280
+ selector: sample.selector,
1281
+ rule: rule.name,
1282
+ property: rule.property,
1283
+ }));
1284
+ continue;
1285
+ }
1286
+ const distance = getColorDistance(actualColor, expectedColor);
1287
+ if (distance > rule.tolerance) {
1288
+ issues.push(createRouteIssue(routeName, viewportName, `Color rule "${rule.name}" expected ${rule.selector} ${rule.property} near ${rule.expected}, but ${sample.selector} resolved to ${sample.value} on ${routeName} [${viewportName}].`, {
1289
+ code: "color_mismatch",
1290
+ selector: sample.selector,
1291
+ rule: rule.name,
1292
+ property: rule.property,
1293
+ text: sample.text,
1294
+ actual: formatColor(actualColor),
1295
+ expected: formatColor(expectedColor),
1296
+ tolerance: rule.tolerance,
1297
+ distance: Number(distance.toFixed(2)),
1298
+ }));
1299
+ }
1300
+ }
1301
+ }
1302
+ return issues;
1303
+ }
1304
+
1305
+ async function runContrastChecks(page, rules, routeName, viewportName) {
1306
+ const issues = [];
1307
+ for (const rule of rules) {
1308
+ const samples = await page.evaluate((input) => {
1309
+ const clamp = (value) => Math.max(0, Math.min(255, Math.round(value)));
1310
+ const parse = (value) => {
1311
+ const normalized = String(value || "").trim().toLowerCase();
1312
+ if (!normalized) {
1313
+ return null;
1314
+ }
1315
+ if (normalized === "transparent") {
1316
+ return { r: 0, g: 0, b: 0, a: 0 };
1317
+ }
1318
+ const hexMatch = normalized.match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
1319
+ if (hexMatch) {
1320
+ let hexValue = hexMatch[1];
1321
+ if (hexValue.length === 3 || hexValue.length === 4) {
1322
+ hexValue = hexValue.split("").map(character => `${character}${character}`).join("");
1323
+ }
1324
+ return {
1325
+ r: Number.parseInt(hexValue.slice(0, 2), 16),
1326
+ g: Number.parseInt(hexValue.slice(2, 4), 16),
1327
+ b: Number.parseInt(hexValue.slice(4, 6), 16),
1328
+ a: hexValue.length === 8 ? Number.parseInt(hexValue.slice(6, 8), 16) / 255 : 1,
1329
+ };
1330
+ }
1331
+ const rgbMatch = normalized.match(/^rgba?\(([^)]+)\)$/);
1332
+ if (!rgbMatch) {
1333
+ return null;
1334
+ }
1335
+ const parts = rgbMatch[1].split(",").map(part => part.trim());
1336
+ if (parts.length < 3) {
1337
+ return null;
1338
+ }
1339
+ const alpha = parts[3] !== undefined ? Number.parseFloat(parts[3]) : 1;
1340
+ return {
1341
+ r: clamp(Number.parseFloat(parts[0])),
1342
+ g: clamp(Number.parseFloat(parts[1])),
1343
+ b: clamp(Number.parseFloat(parts[2])),
1344
+ a: Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1,
1345
+ };
1346
+ };
1347
+ const blend = (foreground, background) => {
1348
+ const alpha = Number.isFinite(foreground?.a) ? Math.max(0, Math.min(1, foreground.a)) : 1;
1349
+ return {
1350
+ r: clamp((foreground?.r || 0) * alpha + (background?.r || 0) * (1 - alpha)),
1351
+ g: clamp((foreground?.g || 0) * alpha + (background?.g || 0) * (1 - alpha)),
1352
+ b: clamp((foreground?.b || 0) * alpha + (background?.b || 0) * (1 - alpha)),
1353
+ a: 1,
1354
+ };
1355
+ };
1356
+ const luminance = (channel) => {
1357
+ const normalized = channel / 255;
1358
+ return normalized <= 0.03928 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4);
1359
+ };
1360
+ const contrastRatio = (foreground, background) => {
1361
+ const effectiveBackground = background?.a !== undefined && background.a < 1
1362
+ ? blend(background, { r: 255, g: 255, b: 255, a: 1 })
1363
+ : { ...(background || { r: 255, g: 255, b: 255, a: 1 }), a: 1 };
1364
+ const effectiveForeground = foreground?.a !== undefined && foreground.a < 1
1365
+ ? blend(foreground, effectiveBackground)
1366
+ : { ...(foreground || { r: 0, g: 0, b: 0, a: 1 }), a: 1 };
1367
+ const foregroundLuminance = (0.2126 * luminance(effectiveForeground.r)) +
1368
+ (0.7152 * luminance(effectiveForeground.g)) +
1369
+ (0.0722 * luminance(effectiveForeground.b));
1370
+ const backgroundLuminance = (0.2126 * luminance(effectiveBackground.r)) +
1371
+ (0.7152 * luminance(effectiveBackground.g)) +
1372
+ (0.0722 * luminance(effectiveBackground.b));
1373
+ const lighter = Math.max(foregroundLuminance, backgroundLuminance);
1374
+ const darker = Math.min(foregroundLuminance, backgroundLuminance);
1375
+ return (lighter + 0.05) / (darker + 0.05);
1376
+ };
1377
+ const describe = (element) => {
1378
+ const parts = [element.tagName.toLowerCase()];
1379
+ if (element.id) {
1380
+ parts.push(`#${element.id}`);
1381
+ }
1382
+ if (element.classList && element.classList.length > 0) {
1383
+ parts.push(`.${Array.from(element.classList).slice(0, 2).join(".")}`);
1384
+ }
1385
+ return parts.join("");
1386
+ };
1387
+ const uniqueElements = Array.from(new Set(input.selectors
1388
+ .flatMap(selector => {
1389
+ try {
1390
+ return Array.from(document.querySelectorAll(selector));
1391
+ }
1392
+ catch {
1393
+ return [];
1394
+ }
1395
+ })));
1396
+ const results = [];
1397
+ for (const element of uniqueElements) {
1398
+ if (results.length >= input.maxIssues) {
1399
+ break;
1400
+ }
1401
+ const style = window.getComputedStyle(element);
1402
+ const rect = element.getBoundingClientRect();
1403
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0 || rect.width <= 0 || rect.height <= 0) {
1404
+ continue;
1405
+ }
1406
+ const text = (element.textContent || element.getAttribute("aria-label") || "").trim().replace(/\s+/g, " ");
1407
+ if (!text) {
1408
+ continue;
1409
+ }
1410
+ const foreground = parse(style.color);
1411
+ if (!foreground) {
1412
+ continue;
1413
+ }
1414
+ let cursor = element;
1415
+ let background = null;
1416
+ while (cursor) {
1417
+ const backgroundColor = parse(window.getComputedStyle(cursor).backgroundColor);
1418
+ if (backgroundColor && backgroundColor.a > 0.01) {
1419
+ background = backgroundColor;
1420
+ break;
1421
+ }
1422
+ cursor = cursor.parentElement;
1423
+ }
1424
+ const ratio = contrastRatio(foreground, background || { r: 255, g: 255, b: 255, a: 1 });
1425
+ if (ratio < input.minRatio) {
1426
+ results.push({
1427
+ selector: describe(element),
1428
+ text: text.slice(0, 120),
1429
+ ratio,
1430
+ foreground: style.color,
1431
+ background: background ? `rgba(${background.r}, ${background.g}, ${background.b}, ${background.a.toFixed(2)})` : "rgba(255, 255, 255, 1.00)",
1432
+ });
1433
+ }
1434
+ }
1435
+ return results;
1436
+ }, {
1437
+ selectors: rule.selectors,
1438
+ minRatio: rule.min_ratio,
1439
+ maxIssues: rule.max_issues,
1440
+ });
1441
+ for (const sample of samples) {
1442
+ issues.push(createRouteIssue(routeName, viewportName, `Contrast rule "${rule.name}" found low contrast (${sample.ratio.toFixed(2)}) on ${sample.selector} in ${routeName} [${viewportName}].`, {
1443
+ code: "contrast_failed",
1444
+ selector: sample.selector,
1445
+ rule: rule.name,
1446
+ text: sample.text,
1447
+ ratio: Number(sample.ratio.toFixed(2)),
1448
+ min_ratio: rule.min_ratio,
1449
+ foreground: sample.foreground,
1450
+ background: sample.background,
1451
+ }));
1452
+ }
1453
+ }
1454
+ return issues;
1455
+ }
1456
+
1457
+ async function compareAgainstBaseline(modules, screenshotPath, baselinePath, diffPath, routeConfig, issueContext = {}) {
1458
+ if (!baselinePath) {
1459
+ return {
1460
+ ok: false,
1461
+ issues: [createUiIssue("Baseline screenshot is not configured for this route.", {
1462
+ code: "baseline_missing",
1463
+ ...issueContext,
1464
+ })],
1465
+ artifacts: [],
1466
+ };
1467
+ }
1468
+ if (!(await fs.pathExists(baselinePath))) {
1469
+ return {
1470
+ ok: false,
1471
+ issues: [createUiIssue(`Baseline screenshot is missing: ${baselinePath}`, {
1472
+ code: "baseline_missing",
1473
+ path: baselinePath,
1474
+ ...issueContext,
1475
+ })],
1476
+ artifacts: [],
1477
+ };
1478
+ }
1479
+ if (!modules.pixelmatch || !modules.PNG) {
1480
+ return {
1481
+ ok: false,
1482
+ issues: [createUiIssue("pixelmatch/pngjs is not available, so visual diff cannot run.", {
1483
+ code: "visual_diff_unavailable",
1484
+ ...issueContext,
1485
+ })],
1486
+ artifacts: [],
1487
+ };
1488
+ }
1489
+ const threshold = Number.isFinite(routeConfig?.diff_threshold) && routeConfig.diff_threshold >= 0
1490
+ ? Number(routeConfig.diff_threshold)
1491
+ : 0.01;
1492
+ const baselinePng = modules.PNG.sync.read(await fs.readFile(baselinePath));
1493
+ const actualPng = modules.PNG.sync.read(await fs.readFile(screenshotPath));
1494
+ if (baselinePng.width !== actualPng.width || baselinePng.height !== actualPng.height) {
1495
+ return {
1496
+ ok: false,
1497
+ issues: [createUiIssue(`Baseline dimensions ${baselinePng.width}x${baselinePng.height} do not match actual screenshot ${actualPng.width}x${actualPng.height}.`, {
1498
+ code: "baseline_size_mismatch",
1499
+ expected: `${baselinePng.width}x${baselinePng.height}`,
1500
+ actual: `${actualPng.width}x${actualPng.height}`,
1501
+ ...issueContext,
1502
+ })],
1503
+ artifacts: [],
1504
+ };
1505
+ }
1506
+ const diffPng = new modules.PNG({ width: actualPng.width, height: actualPng.height });
1507
+ const diffPixels = modules.pixelmatch(actualPng.data, baselinePng.data, diffPng.data, actualPng.width, actualPng.height, {
1508
+ threshold: 0.1,
1509
+ });
1510
+ const diffRatio = diffPixels / Math.max(1, actualPng.width * actualPng.height);
1511
+ if (diffPixels > 0) {
1512
+ await fs.writeFile(diffPath, modules.PNG.sync.write(diffPng));
1513
+ }
1514
+ return {
1515
+ ok: diffRatio <= threshold,
1516
+ issues: diffRatio <= threshold
1517
+ ? []
1518
+ : [createUiIssue(`Visual diff ratio ${diffRatio.toFixed(4)} exceeds threshold ${threshold.toFixed(4)}.`, {
1519
+ code: "visual_diff_failed",
1520
+ diff_ratio: Number(diffRatio.toFixed(4)),
1521
+ tolerance: Number(threshold.toFixed(4)),
1522
+ path: diffPath,
1523
+ ...issueContext,
1524
+ })],
1525
+ artifacts: diffPixels > 0 ? [{ path: diffPath, label: "visual diff", type: "diff" }] : [],
1526
+ diffRatio,
1527
+ };
1528
+ }
1529
+
1530
+ async function runUiReview(playwright, modules, projectPath, checkpointConfig, artifactPaths, storageStatePath) {
1531
+ debugLog("ui_review:start", projectPath);
1532
+ const workspaceRoot = path.join(projectPath, ".ospec", "plugins", "checkpoint");
1533
+ const routesPath = path.join(workspaceRoot, "routes.yaml");
1534
+ const routesConfig = await readYamlIfExists(routesPath);
1535
+ if (!routesConfig || !Array.isArray(routesConfig.routes) || routesConfig.routes.length === 0) {
1536
+ return {
1537
+ status: "failed",
1538
+ issues: [createUiIssue("routes.yaml is missing or does not define any routes.", { code: "routes_config_missing", category: "config" })],
1539
+ routes: [],
1540
+ artifacts: [],
1541
+ };
1542
+ }
1543
+ const browser = await playwright.chromium.launch({ headless: true });
1544
+ const defaultsConfig = isObject(routesConfig?.defaults) ? routesConfig.defaults : {};
1545
+ const contextOptions = {
1546
+ ignoreHTTPSErrors: true,
1547
+ baseURL: firstString(checkpointConfig?.runtime?.base_url),
1548
+ };
1549
+ if (storageStatePath && await fs.pathExists(storageStatePath)) {
1550
+ contextOptions.storageState = storageStatePath;
1551
+ }
1552
+ const browserContext = await browser.newContext(contextOptions);
1553
+ const tracePath = path.join(artifactPaths.tracesDir, "ui-review-trace.zip");
1554
+ await browserContext.tracing.start({ screenshots: true, snapshots: true });
1555
+ const issues = [];
1556
+ const routes = [];
1557
+ const artifacts = [];
1558
+ try {
1559
+ for (let index = 0; index < routesConfig.routes.length; index += 1) {
1560
+ const routeConfig = routesConfig.routes[index] || {};
1561
+ const routeName = sanitizeName(firstString(routeConfig.name, routeConfig.path, `route-${index + 1}`), `route-${index + 1}`);
1562
+ const routeUrl = joinUrl(firstString(routeConfig.base_url, checkpointConfig?.runtime?.base_url), firstString(routeConfig.path, routeConfig.url, "/"));
1563
+ const viewports = getRouteViewports(routeConfig, defaultsConfig);
1564
+ const requiredVisibleSelectors = getRequiredVisibleSelectors(routeConfig, defaultsConfig);
1565
+ const noOverlapRules = getNoOverlapRules(routeConfig, defaultsConfig);
1566
+ const typographyRules = getTypographyRules(routeConfig, defaultsConfig);
1567
+ const colorRules = getColorRules(routeConfig, defaultsConfig);
1568
+ const contrastRules = getContrastRules(routeConfig, defaultsConfig);
1569
+ const ignoreSelectors = getRouteIgnoreSelectors(routeConfig, defaultsConfig);
1570
+ const routeRequirements = getRouteRequirements(routeConfig, defaultsConfig);
1571
+ const routeTimeoutMs = Number.isFinite(routeConfig.timeout_ms) && routeConfig.timeout_ms > 0
1572
+ ? Math.floor(routeConfig.timeout_ms)
1573
+ : Number.isFinite(defaultsConfig?.timeout_ms) && defaultsConfig.timeout_ms > 0
1574
+ ? Math.floor(defaultsConfig.timeout_ms)
1575
+ : 60000;
1576
+ const waitAfterLoadMs = Number.isFinite(routeConfig.wait_after_load_ms) && routeConfig.wait_after_load_ms > 0
1577
+ ? Math.floor(routeConfig.wait_after_load_ms)
1578
+ : Number.isFinite(defaultsConfig?.wait_after_load_ms) && defaultsConfig.wait_after_load_ms > 0
1579
+ ? Math.floor(defaultsConfig.wait_after_load_ms)
1580
+ : 0;
1581
+ const fullPage = routeConfig.full_page !== undefined
1582
+ ? routeConfig.full_page !== false
1583
+ : defaultsConfig?.full_page !== false;
1584
+ const diffThreshold = Number.isFinite(routeConfig.diff_threshold) && routeConfig.diff_threshold >= 0
1585
+ ? Number(routeConfig.diff_threshold)
1586
+ : Number.isFinite(defaultsConfig?.diff_threshold) && defaultsConfig.diff_threshold >= 0
1587
+ ? Number(defaultsConfig.diff_threshold)
1588
+ : undefined;
1589
+ for (const viewport of viewports) {
1590
+ debugLog("ui_review:route", routeName, viewport.name, routeUrl);
1591
+ const page = await browserContext.newPage();
1592
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
1593
+ const routeIssues = [];
1594
+ let screenshotPath = "";
1595
+ let baselinePath = "";
1596
+ let diffPath = "";
1597
+ try {
1598
+ await page.goto(routeUrl, {
1599
+ waitUntil: "networkidle",
1600
+ timeout: routeTimeoutMs,
1601
+ });
1602
+ await page.evaluate(async () => {
1603
+ if (document.fonts && document.fonts.ready) {
1604
+ await document.fonts.ready;
1605
+ }
1606
+ });
1607
+ if (waitAfterLoadMs > 0) {
1608
+ await page.waitForTimeout(waitAfterLoadMs);
1609
+ }
1610
+ await applyIgnoredSelectors(page, ignoreSelectors);
1611
+ screenshotPath = path.join(artifactPaths.screenshotsDir, `${routeName}-${viewport.name}.png`);
1612
+ await page.screenshot({
1613
+ path: screenshotPath,
1614
+ fullPage: fullPage !== false,
1615
+ });
1616
+ artifacts.push({
1617
+ path: screenshotPath,
1618
+ label: `${routeName} screenshot`,
1619
+ type: "screenshot",
1620
+ });
1621
+ const layoutSignals = await collectLayoutSignals(page);
1622
+ if (layoutSignals.horizontalOverflow) {
1623
+ routeIssues.push(createRouteIssue(routeName, viewport.name, `Horizontal overflow was detected on ${routeName} [${viewport.name}].`, { code: "horizontal_overflow" }));
1624
+ }
1625
+ for (const clippedEntry of layoutSignals.clippedText) {
1626
+ routeIssues.push(createRouteIssue(routeName, viewport.name, `Potential clipped text at ${clippedEntry.selector} on ${routeName} [${viewport.name}]: ${clippedEntry.text}`, {
1627
+ code: "clipped_text",
1628
+ selector: clippedEntry.selector,
1629
+ text: clippedEntry.text,
1630
+ }));
1631
+ }
1632
+ for (const coveredEntry of layoutSignals.coveredElements || []) {
1633
+ routeIssues.push(createRouteIssue(routeName, viewport.name, `Potentially covered element ${coveredEntry.selector} on ${routeName} [${viewport.name}] is blocked by ${coveredEntry.coveredBy}.`, {
1634
+ code: "covered_element",
1635
+ selector: coveredEntry.selector,
1636
+ covered_by: coveredEntry.coveredBy,
1637
+ text: coveredEntry.text,
1638
+ }));
1639
+ }
1640
+ routeIssues.push(...await runVisibilityChecks(page, requiredVisibleSelectors, routeName, viewport.name));
1641
+ routeIssues.push(...await runOverlapChecks(page, noOverlapRules, routeName, viewport.name));
1642
+ routeIssues.push(...await runTypographyChecks(page, typographyRules, routeName, viewport.name));
1643
+ routeIssues.push(...await runColorChecks(page, colorRules, routeName, viewport.name));
1644
+ routeIssues.push(...await runContrastChecks(page, contrastRules, routeName, viewport.name));
1645
+ baselinePath = resolveViewportBaselinePath(routeConfig, defaultsConfig, viewport.name, workspaceRoot, projectPath);
1646
+ diffPath = path.join(artifactPaths.diffsDir, `${routeName}-${viewport.name}.png`);
1647
+ const visualDiff = await compareAgainstBaseline(modules, screenshotPath, baselinePath, diffPath, diffThreshold === undefined ? routeConfig : { diff_threshold: diffThreshold }, {
1648
+ route: routeName,
1649
+ viewport: viewport.name,
1650
+ });
1651
+ routeIssues.push(...visualDiff.issues);
1652
+ if (visualDiff.artifacts.length > 0) {
1653
+ artifacts.push(...visualDiff.artifacts);
1654
+ }
1655
+ routes.push({
1656
+ name: routeName,
1657
+ url: routeUrl,
1658
+ viewport: viewport.name,
1659
+ viewport_size: `${viewport.width}x${viewport.height}`,
1660
+ screenshot_path: screenshotPath,
1661
+ baseline_path: baselinePath,
1662
+ diff_path: visualDiff.artifacts[0]?.path || "",
1663
+ diff_ratio: Number.isFinite(visualDiff.diffRatio) ? visualDiff.diffRatio : null,
1664
+ requirements: routeRequirements,
1665
+ status: routeIssues.length === 0 ? "passed" : "failed",
1666
+ issues: routeIssues,
1667
+ });
1668
+ debugLog("ui_review:route_done", routeName, viewport.name, routeIssues.length === 0 ? "passed" : "failed");
1669
+ }
1670
+ catch (error) {
1671
+ debugLog("ui_review:route_error", routeName, viewport.name, error.message || String(error));
1672
+ routeIssues.push(createRouteIssue(routeName, viewport.name, `Route ${routeName} [${viewport.name}] failed: ${error.message}`, { code: "route_failed" }));
1673
+ routes.push({
1674
+ name: routeName,
1675
+ url: routeUrl,
1676
+ viewport: viewport.name,
1677
+ viewport_size: `${viewport.width}x${viewport.height}`,
1678
+ screenshot_path: screenshotPath,
1679
+ baseline_path: baselinePath,
1680
+ diff_path: diffPath,
1681
+ diff_ratio: null,
1682
+ requirements: routeRequirements,
1683
+ status: "failed",
1684
+ issues: routeIssues,
1685
+ });
1686
+ }
1687
+ issues.push(...routeIssues);
1688
+ await page.close();
1689
+ }
1690
+ }
1691
+ }
1692
+ finally {
1693
+ debugLog("ui_review:trace_stop");
1694
+ await browserContext.tracing.stop({ path: tracePath }).catch(() => undefined);
1695
+ if (await fs.pathExists(tracePath)) {
1696
+ artifacts.push({
1697
+ path: tracePath,
1698
+ label: "ui review trace",
1699
+ type: "trace",
1700
+ });
1701
+ }
1702
+ await browserContext.close();
1703
+ await browser.close();
1704
+ debugLog("ui_review:done");
1705
+ }
1706
+ return {
1707
+ status: issues.length === 0 ? "passed" : "failed",
1708
+ issues,
1709
+ routes,
1710
+ artifacts,
1711
+ };
1712
+ }
1713
+
1714
+ async function executeFlowStep(page, step, baseUrl, artifactPaths, flowName) {
1715
+ const action = String(step?.action || "").trim().toLowerCase();
1716
+ const timeoutMs = Number.isFinite(step?.timeout_ms) && step.timeout_ms > 0 ? Math.floor(step.timeout_ms) : 30000;
1717
+ if (action === "goto" || action === "visit") {
1718
+ await page.goto(joinUrl(baseUrl, firstString(step.url, step.path, "/")), {
1719
+ waitUntil: firstString(step.wait_until, "networkidle"),
1720
+ timeout: timeoutMs,
1721
+ });
1722
+ return [];
1723
+ }
1724
+ if (action === "wait_for_load") {
1725
+ await page.waitForLoadState(firstString(step.state, "networkidle"), { timeout: timeoutMs });
1726
+ return [];
1727
+ }
1728
+ if (action === "wait_for_selector") {
1729
+ await page.waitForSelector(firstString(step.selector), {
1730
+ timeout: timeoutMs,
1731
+ state: firstString(step.state, "visible"),
1732
+ });
1733
+ return [];
1734
+ }
1735
+ if (action === "click") {
1736
+ await page.locator(firstString(step.selector)).click({ timeout: timeoutMs });
1737
+ return [];
1738
+ }
1739
+ if (action === "fill") {
1740
+ await page.locator(firstString(step.selector)).fill(firstString(step.value), { timeout: timeoutMs });
1741
+ return [];
1742
+ }
1743
+ if (action === "press") {
1744
+ await page.locator(firstString(step.selector)).press(firstString(step.key, "Enter"), { timeout: timeoutMs });
1745
+ return [];
1746
+ }
1747
+ if (action === "assert_text") {
1748
+ const expectedText = firstString(step.text, step.value);
1749
+ const actualText = step.selector
1750
+ ? await page.locator(firstString(step.selector)).textContent({ timeout: timeoutMs })
1751
+ : await page.locator("body").textContent({ timeout: timeoutMs });
1752
+ if (!String(actualText || "").includes(expectedText)) {
1753
+ throw new Error(`Expected text "${expectedText}" was not found.`);
1754
+ }
1755
+ return [];
1756
+ }
1757
+ if (action === "assert_url") {
1758
+ const currentUrl = page.url();
1759
+ const expectedExact = firstString(step.exact);
1760
+ const expectedIncludes = firstString(step.includes, step.url);
1761
+ if (expectedExact && currentUrl !== expectedExact) {
1762
+ throw new Error(`Expected URL "${expectedExact}" but found "${currentUrl}".`);
1763
+ }
1764
+ if (expectedIncludes && !currentUrl.includes(expectedIncludes)) {
1765
+ throw new Error(`Expected URL to include "${expectedIncludes}" but found "${currentUrl}".`);
1766
+ }
1767
+ return [];
1768
+ }
1769
+ if (action === "screenshot") {
1770
+ const screenshotName = sanitizeName(firstString(step.name, `${flowName}-step`), `${flowName}-step`);
1771
+ const screenshotPath = path.join(artifactPaths.screenshotsDir, `${screenshotName}.png`);
1772
+ await page.screenshot({
1773
+ path: screenshotPath,
1774
+ fullPage: step.full_page !== false,
1775
+ });
1776
+ return [{
1777
+ path: screenshotPath,
1778
+ label: `${flowName} screenshot`,
1779
+ type: "screenshot",
1780
+ }];
1781
+ }
1782
+ throw new Error(`Unsupported flow action: ${action || "(empty)"}`);
1783
+ }
1784
+
1785
+ function assertJsonSubset(actual, expected, trail = "", issueContext = {}) {
1786
+ if (expected === null || typeof expected !== "object" || Array.isArray(expected)) {
1787
+ const matches = JSON.stringify(actual) === JSON.stringify(expected);
1788
+ return matches ? [] : [createIssue(`JSON assertion failed at ${trail || "$"}: expected ${JSON.stringify(expected)} but received ${JSON.stringify(actual)}.`, {
1789
+ code: "json_assertion_failed",
1790
+ expected,
1791
+ actual,
1792
+ ...issueContext,
1793
+ })];
1794
+ }
1795
+ if (!actual || typeof actual !== "object" || Array.isArray(actual)) {
1796
+ return [createIssue(`JSON assertion failed at ${trail || "$"}: expected an object.`, {
1797
+ code: "json_assertion_failed",
1798
+ expected,
1799
+ actual,
1800
+ ...issueContext,
1801
+ })];
1802
+ }
1803
+ const issues = [];
1804
+ for (const [key, value] of Object.entries(expected)) {
1805
+ issues.push(...assertJsonSubset(actual[key], value, trail ? `${trail}.${key}` : key, issueContext));
1806
+ }
1807
+ return issues;
1808
+ }
1809
+
1810
+ async function runFlowCheck(playwright, projectPath, checkpointConfig, artifactPaths, storageStatePath) {
1811
+ const workspaceRoot = path.join(projectPath, ".ospec", "plugins", "checkpoint");
1812
+ const flowsPath = path.join(workspaceRoot, "flows.yaml");
1813
+ const flowsConfig = await readYamlIfExists(flowsPath);
1814
+ if (!flowsConfig || !Array.isArray(flowsConfig.flows) || flowsConfig.flows.length === 0) {
1815
+ return {
1816
+ status: "failed",
1817
+ issues: [createIssue("flows.yaml is missing or does not define any flows.", {
1818
+ code: "flows_config_missing",
1819
+ category: "config",
1820
+ severity: "error",
1821
+ step: "checkpoint_flow_check",
1822
+ })],
1823
+ flows: [],
1824
+ artifacts: [],
1825
+ };
1826
+ }
1827
+ const browser = await playwright.chromium.launch({ headless: true });
1828
+ const contextOptions = {
1829
+ ignoreHTTPSErrors: true,
1830
+ baseURL: firstString(checkpointConfig?.runtime?.base_url),
1831
+ };
1832
+ if (storageStatePath && await fs.pathExists(storageStatePath)) {
1833
+ contextOptions.storageState = storageStatePath;
1834
+ }
1835
+ const browserContext = await browser.newContext(contextOptions);
1836
+ const requestContext = await playwright.request.newContext({
1837
+ baseURL: firstString(checkpointConfig?.runtime?.base_url),
1838
+ ignoreHTTPSErrors: true,
1839
+ storageState: storageStatePath && await fs.pathExists(storageStatePath) ? storageStatePath : undefined,
1840
+ });
1841
+ const tracePath = path.join(artifactPaths.tracesDir, "flow-check-trace.zip");
1842
+ await browserContext.tracing.start({ screenshots: true, snapshots: true });
1843
+ const issues = [];
1844
+ const flows = [];
1845
+ const artifacts = [];
1846
+ try {
1847
+ for (let index = 0; index < flowsConfig.flows.length; index += 1) {
1848
+ const flowConfig = flowsConfig.flows[index] || {};
1849
+ const flowName = sanitizeName(firstString(flowConfig.name, `flow-${index + 1}`), `flow-${index + 1}`);
1850
+ const page = await browserContext.newPage();
1851
+ const flowIssues = [];
1852
+ const flowArtifacts = [];
1853
+ try {
1854
+ const startUrl = firstString(flowConfig.start_url, flowConfig.url);
1855
+ if (startUrl) {
1856
+ await page.goto(joinUrl(firstString(checkpointConfig?.runtime?.base_url), startUrl), {
1857
+ waitUntil: "networkidle",
1858
+ timeout: 60000,
1859
+ });
1860
+ }
1861
+ for (const step of Array.isArray(flowConfig.steps) ? flowConfig.steps : []) {
1862
+ const producedArtifacts = await executeFlowStep(page, step, firstString(checkpointConfig?.runtime?.base_url), artifactPaths, flowName);
1863
+ flowArtifacts.push(...producedArtifacts);
1864
+ }
1865
+ for (const assertion of Array.isArray(flowConfig.api_assertions) ? flowConfig.api_assertions : []) {
1866
+ const response = await requestContext.fetch(joinUrl(firstString(checkpointConfig?.runtime?.base_url), firstString(assertion.url, assertion.path)), {
1867
+ method: firstString(assertion.method, "GET"),
1868
+ headers: assertion.headers && typeof assertion.headers === "object" ? assertion.headers : {},
1869
+ });
1870
+ const expectedStatus = Number.isFinite(assertion.expect_status) ? Math.floor(assertion.expect_status) : 200;
1871
+ if (response.status() !== expectedStatus) {
1872
+ flowIssues.push(createFlowIssue(flowName, `API assertion for ${assertion.url || assertion.path} expected status ${expectedStatus} but received ${response.status()}.`, {
1873
+ code: "api_status_failed",
1874
+ url: firstString(assertion.url, assertion.path),
1875
+ expected: expectedStatus,
1876
+ actual: response.status(),
1877
+ }));
1878
+ }
1879
+ if (assertion.expect_text) {
1880
+ const responseText = await response.text();
1881
+ if (!responseText.includes(String(assertion.expect_text))) {
1882
+ flowIssues.push(createFlowIssue(flowName, `API assertion for ${assertion.url || assertion.path} did not include expected text "${assertion.expect_text}".`, {
1883
+ code: "api_text_failed",
1884
+ url: firstString(assertion.url, assertion.path),
1885
+ expected: assertion.expect_text,
1886
+ actual: responseText.slice(0, 240),
1887
+ }));
1888
+ }
1889
+ }
1890
+ if (assertion.expect_json && response.ok()) {
1891
+ try {
1892
+ const responseJson = await response.json();
1893
+ flowIssues.push(...assertJsonSubset(responseJson, assertion.expect_json, "", {
1894
+ step: "checkpoint_flow_check",
1895
+ flow: flowName,
1896
+ url: firstString(assertion.url, assertion.path),
1897
+ }).map(issue => ({
1898
+ ...issue,
1899
+ code: issue.code || "api_json_failed",
1900
+ })));
1901
+ }
1902
+ catch (error) {
1903
+ flowIssues.push(createFlowIssue(flowName, `API assertion for ${assertion.url || assertion.path} could not parse JSON: ${error.message}`, {
1904
+ code: "api_json_failed",
1905
+ url: firstString(assertion.url, assertion.path),
1906
+ }));
1907
+ }
1908
+ }
1909
+ }
1910
+ const assertCommand = firstString(flowConfig.assert_command);
1911
+ if (assertCommand) {
1912
+ const result = spawnSync(assertCommand, {
1913
+ cwd: projectPath,
1914
+ env: {
1915
+ ...process.env,
1916
+ OSPEC_CHECKPOINT_BASE_URL: firstString(checkpointConfig?.runtime?.base_url),
1917
+ OSPEC_CHECKPOINT_FLOW_NAME: flowName,
1918
+ },
1919
+ encoding: "utf-8",
1920
+ shell: true,
1921
+ timeout: 300000,
1922
+ });
1923
+ if (result.error) {
1924
+ flowIssues.push(createFlowIssue(flowName, `assert_command failed to start: ${result.error.message}`, { code: "assert_command_failed" }));
1925
+ }
1926
+ else if (result.status !== 0) {
1927
+ flowIssues.push(createFlowIssue(flowName, `assert_command exited with status ${result.status}: ${String(result.stderr || result.stdout || "").trim()}`, {
1928
+ code: "assert_command_failed",
1929
+ actual: result.status,
1930
+ details: String(result.stderr || result.stdout || "").trim(),
1931
+ }));
1932
+ }
1933
+ }
1934
+ }
1935
+ catch (error) {
1936
+ flowIssues.push(createFlowIssue(flowName, `Flow ${flowName} failed: ${error.message}`, { code: "flow_failed" }));
1937
+ }
1938
+ issues.push(...flowIssues);
1939
+ artifacts.push(...flowArtifacts);
1940
+ flows.push({
1941
+ name: flowName,
1942
+ status: flowIssues.length === 0 ? "passed" : "failed",
1943
+ issues: flowIssues,
1944
+ artifacts: flowArtifacts,
1945
+ });
1946
+ await page.close();
1947
+ }
1948
+ }
1949
+ finally {
1950
+ await browserContext.tracing.stop({ path: tracePath }).catch(() => undefined);
1951
+ if (await fs.pathExists(tracePath)) {
1952
+ artifacts.push({
1953
+ path: tracePath,
1954
+ label: "flow check trace",
1955
+ type: "trace",
1956
+ });
1957
+ }
1958
+ await requestContext.dispose().catch(() => undefined);
1959
+ await browserContext.close();
1960
+ await browser.close();
1961
+ }
1962
+ return {
1963
+ status: issues.length === 0 ? "passed" : "failed",
1964
+ issues,
1965
+ flows,
1966
+ artifacts,
1967
+ };
1968
+ }
1969
+
1970
+ function formatIssueScope(issue) {
1971
+ const parts = [];
1972
+ if (issue.route) {
1973
+ parts.push(`route:${issue.route}`);
1974
+ }
1975
+ if (issue.viewport) {
1976
+ parts.push(`viewport:${issue.viewport}`);
1977
+ }
1978
+ if (issue.flow) {
1979
+ parts.push(`flow:${issue.flow}`);
1980
+ }
1981
+ if (issue.selector) {
1982
+ parts.push(`selector:${issue.selector}`);
1983
+ }
1984
+ return parts.join(", ");
1985
+ }
1986
+
1987
+ function formatIssueLabel(issue) {
1988
+ const category = firstString(issue?.category, "runtime");
1989
+ const severity = firstString(issue?.severity, "error");
1990
+ return `[${category}/${severity}]`;
1991
+ }
1992
+
1993
+ function summarizeIssues(issues) {
1994
+ const entries = Array.isArray(issues) ? issues : [];
1995
+ const byCategory = {};
1996
+ const bySeverity = {};
1997
+ for (const issue of entries) {
1998
+ const category = firstString(issue?.category, "runtime");
1999
+ const severity = firstString(issue?.severity, "error");
2000
+ byCategory[category] = (byCategory[category] || 0) + 1;
2001
+ bySeverity[severity] = (bySeverity[severity] || 0) + 1;
2002
+ }
2003
+ return {
2004
+ total: entries.length,
2005
+ byCategory,
2006
+ bySeverity,
2007
+ };
2008
+ }
2009
+
2010
+ function buildSummary(result) {
2011
+ const overallIssueSummary = summarizeIssues(result.issues || []);
2012
+ const lines = [
2013
+ "# Checkpoint Summary",
2014
+ "",
2015
+ `- Status: ${result.status}`,
2016
+ `- Executed at: ${result.executed_at}`,
2017
+ `- Total issues: ${overallIssueSummary.total}`,
2018
+ "",
2019
+ ];
2020
+ if (overallIssueSummary.total > 0) {
2021
+ lines.push("## Issue Overview");
2022
+ lines.push("");
2023
+ lines.push("- By category:");
2024
+ Object.entries(overallIssueSummary.byCategory)
2025
+ .sort((left, right) => left[0].localeCompare(right[0]))
2026
+ .forEach(([category, count]) => {
2027
+ lines.push(` - ${category}: ${count}`);
2028
+ });
2029
+ lines.push("- By severity:");
2030
+ Object.entries(overallIssueSummary.bySeverity)
2031
+ .sort((left, right) => left[0].localeCompare(right[0]))
2032
+ .forEach(([severity, count]) => {
2033
+ lines.push(` - ${severity}: ${count}`);
2034
+ });
2035
+ lines.push("");
2036
+ }
2037
+ for (const [stepName, stepResult] of Object.entries(result.steps || {})) {
2038
+ const stepIssueSummary = summarizeIssues(stepResult.issues || []);
2039
+ lines.push(`## ${stepName}`);
2040
+ lines.push("");
2041
+ lines.push(`- Status: ${stepResult.status}`);
2042
+ lines.push(`- Issue count: ${stepIssueSummary.total}`);
2043
+ if (Array.isArray(stepResult.routes) && stepResult.routes.length > 0) {
2044
+ lines.push(`- Routes: ${stepResult.routes.length}`);
2045
+ lines.push("- Route results:");
2046
+ stepResult.routes.forEach(route => {
2047
+ lines.push(` - ${route.name} [${route.viewport || "default"}]: ${route.status}`);
2048
+ });
2049
+ }
2050
+ if (Array.isArray(stepResult.flows) && stepResult.flows.length > 0) {
2051
+ lines.push(`- Flows: ${stepResult.flows.length}`);
2052
+ lines.push("- Flow results:");
2053
+ stepResult.flows.forEach(flow => {
2054
+ lines.push(` - ${flow.name}: ${flow.status}`);
2055
+ });
2056
+ }
2057
+ if (Array.isArray(stepResult.issues) && stepResult.issues.length > 0) {
2058
+ lines.push("- Categories:");
2059
+ Object.entries(stepIssueSummary.byCategory)
2060
+ .sort((left, right) => left[0].localeCompare(right[0]))
2061
+ .forEach(([category, count]) => {
2062
+ lines.push(` - ${category}: ${count}`);
2063
+ });
2064
+ lines.push("- Issues:");
2065
+ stepResult.issues.forEach(issue => {
2066
+ const scope = formatIssueScope(issue);
2067
+ lines.push(` - ${formatIssueLabel(issue)}${scope ? ` ${scope}` : ""} ${issue.message}`);
2068
+ });
2069
+ }
2070
+ else {
2071
+ lines.push("- Issues: none");
2072
+ }
2073
+ lines.push("");
2074
+ }
2075
+ return lines.join("\n");
2076
+ }
2077
+
2078
+ async function main() {
2079
+ const { changePath, projectPath } = parseArgs(process.argv);
2080
+ debugLog("main:start", changePath, projectPath);
2081
+ const executedAt = new Date().toISOString();
2082
+ const artifactPaths = {
2083
+ checkpointDir: path.join(changePath, "artifacts", "checkpoint"),
2084
+ screenshotsDir: process.env.OSPEC_CHECKPOINT_SCREENSHOTS_DIR || path.join(changePath, "artifacts", "checkpoint", "screenshots"),
2085
+ diffsDir: process.env.OSPEC_CHECKPOINT_DIFFS_DIR || path.join(changePath, "artifacts", "checkpoint", "diffs"),
2086
+ tracesDir: process.env.OSPEC_CHECKPOINT_TRACES_DIR || path.join(changePath, "artifacts", "checkpoint", "traces"),
2087
+ };
2088
+ await fs.ensureDir(artifactPaths.checkpointDir);
2089
+ await fs.ensureDir(artifactPaths.screenshotsDir);
2090
+ await fs.ensureDir(artifactPaths.diffsDir);
2091
+ await fs.ensureDir(artifactPaths.tracesDir);
2092
+ const config = await loadJson(path.join(projectPath, ".skillrc"));
2093
+ const checkpointConfig = config?.plugins?.checkpoint || {};
2094
+ const verification = matter(await fs.readFile(path.join(changePath, "verification.md"), "utf8"));
2095
+ const optionalSteps = Array.isArray(verification.data.optional_steps) ? verification.data.optional_steps : [];
2096
+ const activeSteps = optionalSteps.filter(step => step === "checkpoint_ui_review" || step === "checkpoint_flow_check");
2097
+ const result = {
2098
+ ok: false,
2099
+ status: "pending",
2100
+ executed_at: executedAt,
2101
+ issues: [],
2102
+ steps: {},
2103
+ metadata: {
2104
+ base_url: firstString(checkpointConfig?.runtime?.base_url),
2105
+ change_path: changePath,
2106
+ project_path: projectPath,
2107
+ },
2108
+ artifacts: [],
2109
+ };
2110
+ if (activeSteps.length === 0) {
2111
+ result.status = "failed";
2112
+ result.issues.push(createRuntimeIssue("No checkpoint steps are active for this change.", {
2113
+ code: "checkpoint_steps_missing",
2114
+ category: "config",
2115
+ }));
2116
+ result.summary_markdown = buildSummary(result);
2117
+ console.log(JSON.stringify(result));
2118
+ return;
2119
+ }
2120
+ const storageStatePath = resolveMaybePath(firstString(checkpointConfig?.runtime?.storage_state), projectPath, projectPath);
2121
+ const runtimeContext = {
2122
+ change_path: changePath,
2123
+ project_path: projectPath,
2124
+ ospec_package_path: path.resolve(__dirname, "..", ".."),
2125
+ base_url: firstString(checkpointConfig?.runtime?.base_url),
2126
+ storage_state_path: storageStatePath,
2127
+ };
2128
+ const startupLogPath = path.join(artifactPaths.tracesDir, "startup.log");
2129
+ const authLogPath = path.join(artifactPaths.tracesDir, "auth.log");
2130
+ debugLog("main:startup_begin");
2131
+ const startupState = await startRuntime(checkpointConfig?.runtime?.startup || {}, runtimeContext, startupLogPath).catch(error => ({
2132
+ started: false,
2133
+ child: null,
2134
+ logPath: "",
2135
+ error,
2136
+ }));
2137
+ try {
2138
+ if (startupState?.error) {
2139
+ throw startupState.error;
2140
+ }
2141
+ if (startupState?.logPath) {
2142
+ result.artifacts.push({
2143
+ path: startupState.logPath,
2144
+ label: "startup log",
2145
+ type: "log",
2146
+ });
2147
+ }
2148
+ debugLog("main:readiness_begin", firstString(checkpointConfig?.runtime?.readiness?.url, checkpointConfig?.runtime?.base_url));
2149
+ const readiness = await waitForReadiness(checkpointConfig?.runtime?.readiness || {}, firstString(checkpointConfig?.runtime?.base_url), startupState);
2150
+ debugLog("main:readiness_done", readiness.ok ? "ok" : "failed");
2151
+ if (!readiness.ok) {
2152
+ result.status = "failed";
2153
+ result.issues.push(createRuntimeIssue(readiness.message, { code: "readiness_failed" }));
2154
+ result.summary_markdown = buildSummary(result);
2155
+ console.log(JSON.stringify(result));
2156
+ return;
2157
+ }
2158
+ const authCommand = firstString(checkpointConfig?.runtime?.auth?.command);
2159
+ if (authCommand && !storageStatePath) {
2160
+ result.status = "failed";
2161
+ result.issues.push(createRuntimeIssue("runtime.auth is configured but runtime.storage_state is empty.", { code: "auth_storage_state_unconfigured" }));
2162
+ result.summary_markdown = buildSummary(result);
2163
+ console.log(JSON.stringify(result));
2164
+ return;
2165
+ }
2166
+ debugLog("main:auth_begin");
2167
+ const authState = await runAuthCommand(checkpointConfig?.runtime?.auth || {}, runtimeContext, storageStatePath, authLogPath).catch(error => ({
2168
+ ran: false,
2169
+ logPath: authLogPath,
2170
+ error,
2171
+ }));
2172
+ if (authState?.logPath) {
2173
+ result.artifacts.push({
2174
+ path: authState.logPath,
2175
+ label: "auth log",
2176
+ type: "log",
2177
+ });
2178
+ }
2179
+ if (authState?.error) {
2180
+ result.status = "failed";
2181
+ result.issues.push(createRuntimeIssue(authState.error.message || String(authState.error), { code: "auth_failed" }));
2182
+ result.summary_markdown = buildSummary(result);
2183
+ console.log(JSON.stringify(result));
2184
+ return;
2185
+ }
2186
+ if (authState?.ran) {
2187
+ debugLog("main:auth_done", "ran");
2188
+ }
2189
+ else {
2190
+ debugLog("main:auth_done", "skipped");
2191
+ }
2192
+ if (authState?.ran && storageStatePath && !(await fs.pathExists(storageStatePath))) {
2193
+ result.status = "failed";
2194
+ result.issues.push(createRuntimeIssue(`Auth command completed but did not create the configured storage state file: ${storageStatePath}`, {
2195
+ code: "auth_storage_state_missing",
2196
+ path: storageStatePath,
2197
+ }));
2198
+ result.summary_markdown = buildSummary(result);
2199
+ console.log(JSON.stringify(result));
2200
+ return;
2201
+ }
2202
+ const playwright = resolveModule(projectPath, "playwright");
2203
+ if (!playwright) {
2204
+ result.status = "failed";
2205
+ result.issues.push(createRuntimeIssue("Playwright is not installed. Install the \"playwright\" package to use checkpoint.", { code: "playwright_missing" }));
2206
+ result.summary_markdown = buildSummary(result);
2207
+ console.log(JSON.stringify(result));
2208
+ return;
2209
+ }
2210
+ const pixelmatchModule = resolveModule(projectPath, "pixelmatch");
2211
+ const pngjsModule = resolveModule(projectPath, "pngjs");
2212
+ const modules = {
2213
+ pixelmatch: pixelmatchModule ? (pixelmatchModule.default || pixelmatchModule) : null,
2214
+ PNG: pngjsModule?.PNG || null,
2215
+ };
2216
+ if (activeSteps.includes("checkpoint_ui_review")) {
2217
+ debugLog("main:run_ui_review");
2218
+ const uiReview = await runUiReview(playwright, modules, projectPath, checkpointConfig, artifactPaths, storageStatePath);
2219
+ result.steps.checkpoint_ui_review = uiReview;
2220
+ result.issues.push(...uiReview.issues);
2221
+ result.artifacts.push(...uiReview.artifacts);
2222
+ debugLog("main:ui_review_done", uiReview.status);
2223
+ }
2224
+ if (activeSteps.includes("checkpoint_flow_check")) {
2225
+ const flowCheck = await runFlowCheck(playwright, projectPath, checkpointConfig, artifactPaths, storageStatePath);
2226
+ result.steps.checkpoint_flow_check = flowCheck;
2227
+ result.issues.push(...flowCheck.issues);
2228
+ result.artifacts.push(...flowCheck.artifacts);
2229
+ }
2230
+ const hasFailedStep = activeSteps.some(step => normalizeStatus(result.steps?.[step]?.status, "failed") === "failed");
2231
+ result.status = hasFailedStep ? "failed" : "passed";
2232
+ result.ok = result.status === "passed";
2233
+ result.summary_markdown = buildSummary(result);
2234
+ debugLog("main:complete", result.status);
2235
+ console.log(JSON.stringify(result));
2236
+ }
2237
+ catch (error) {
2238
+ debugLog("main:error", error.message || String(error));
2239
+ result.status = "failed";
2240
+ result.ok = false;
2241
+ result.issues.push(createRuntimeIssue(error.message || String(error), { code: "checkpoint_runtime_failed" }));
2242
+ result.summary_markdown = buildSummary(result);
2243
+ console.log(JSON.stringify(result));
2244
+ }
2245
+ finally {
2246
+ await stopRuntime(checkpointConfig?.runtime?.shutdown || {}, startupState || {}, runtimeContext).catch(() => undefined);
2247
+ }
2248
+ }
2249
+
2250
+ main().catch(error => {
2251
+ const fallback = {
2252
+ ok: false,
2253
+ status: "failed",
2254
+ executed_at: new Date().toISOString(),
2255
+ issues: [createRuntimeIssue(error.message || String(error), { code: "checkpoint_runtime_failed" })],
2256
+ steps: {},
2257
+ summary_markdown: `# Checkpoint Summary\n\n- Status: failed\n- Error: ${error.message || String(error)}\n`,
2258
+ };
2259
+ console.log(JSON.stringify(fallback));
2260
+ });