@mozole/cli 1.4.2 → 2.0.0-b

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 (229) hide show
  1. package/LICENSE +16 -4
  2. package/README.md +85 -89
  3. package/dist/cli.d.ts +2 -2
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +204 -157
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/adopt.d.ts +5 -8
  8. package/dist/commands/adopt.d.ts.map +1 -1
  9. package/dist/commands/adopt.js +49 -812
  10. package/dist/commands/adopt.js.map +1 -1
  11. package/dist/commands/doctor.d.ts +1 -7
  12. package/dist/commands/doctor.d.ts.map +1 -1
  13. package/dist/commands/doctor.js +69 -377
  14. package/dist/commands/doctor.js.map +1 -1
  15. package/dist/commands/new.d.ts +8 -10
  16. package/dist/commands/new.d.ts.map +1 -1
  17. package/dist/commands/new.js +32 -149
  18. package/dist/commands/new.js.map +1 -1
  19. package/dist/commands/phase.d.ts +11 -0
  20. package/dist/commands/phase.d.ts.map +1 -0
  21. package/dist/commands/phase.js +106 -0
  22. package/dist/commands/phase.js.map +1 -0
  23. package/dist/commands/prototype.d.ts +6 -0
  24. package/dist/commands/prototype.d.ts.map +1 -0
  25. package/dist/commands/prototype.js +28 -0
  26. package/dist/commands/prototype.js.map +1 -0
  27. package/dist/commands/repomap.d.ts +6 -0
  28. package/dist/commands/repomap.d.ts.map +1 -0
  29. package/dist/commands/repomap.js +71 -0
  30. package/dist/commands/repomap.js.map +1 -0
  31. package/dist/commands/test.d.ts +6 -0
  32. package/dist/commands/test.d.ts.map +1 -0
  33. package/dist/commands/test.js +115 -0
  34. package/dist/commands/test.js.map +1 -0
  35. package/dist/commands/ui.d.ts +1 -37
  36. package/dist/commands/ui.d.ts.map +1 -1
  37. package/dist/commands/ui.js +75 -317
  38. package/dist/commands/ui.js.map +1 -1
  39. package/dist/commands/verify.d.ts +12 -7
  40. package/dist/commands/verify.d.ts.map +1 -1
  41. package/dist/commands/verify.js +148 -65
  42. package/dist/commands/verify.js.map +1 -1
  43. package/dist/qa/a11y.d.ts +24 -0
  44. package/dist/qa/a11y.d.ts.map +1 -0
  45. package/dist/qa/a11y.js +124 -0
  46. package/dist/qa/a11y.js.map +1 -0
  47. package/dist/qa/breakpoints.d.ts +16 -0
  48. package/dist/qa/breakpoints.d.ts.map +1 -0
  49. package/dist/qa/breakpoints.js +45 -0
  50. package/dist/qa/breakpoints.js.map +1 -0
  51. package/dist/qa/contract.d.ts +21 -0
  52. package/dist/qa/contract.d.ts.map +1 -0
  53. package/dist/qa/contract.js +116 -0
  54. package/dist/qa/contract.js.map +1 -0
  55. package/dist/qa/index.d.ts +6 -0
  56. package/dist/qa/index.d.ts.map +1 -0
  57. package/dist/qa/index.js +6 -0
  58. package/dist/qa/index.js.map +1 -0
  59. package/dist/qa/probe.d.ts +48 -0
  60. package/dist/qa/probe.d.ts.map +1 -0
  61. package/dist/qa/probe.js +206 -0
  62. package/dist/qa/probe.js.map +1 -0
  63. package/dist/qa/runner.d.ts +27 -32
  64. package/dist/qa/runner.d.ts.map +1 -1
  65. package/dist/qa/runner.js +286 -336
  66. package/dist/qa/runner.js.map +1 -1
  67. package/dist/scaffold/agents.d.ts +5 -0
  68. package/dist/scaffold/agents.d.ts.map +1 -0
  69. package/dist/scaffold/agents.js +84 -0
  70. package/dist/scaffold/agents.js.map +1 -0
  71. package/dist/scaffold/backend.d.ts +6 -8
  72. package/dist/scaffold/backend.d.ts.map +1 -1
  73. package/dist/scaffold/backend.js +318 -199
  74. package/dist/scaffold/backend.js.map +1 -1
  75. package/dist/scaffold/design.d.ts +4 -3
  76. package/dist/scaffold/design.d.ts.map +1 -1
  77. package/dist/scaffold/design.js +38 -47
  78. package/dist/scaffold/design.js.map +1 -1
  79. package/dist/scaffold/flagship.d.ts +8 -0
  80. package/dist/scaffold/flagship.d.ts.map +1 -0
  81. package/dist/scaffold/flagship.js +337 -0
  82. package/dist/scaffold/flagship.js.map +1 -0
  83. package/dist/scaffold/index.d.ts +9 -0
  84. package/dist/scaffold/index.d.ts.map +1 -0
  85. package/dist/scaffold/index.js +48 -0
  86. package/dist/scaffold/index.js.map +1 -0
  87. package/dist/scaffold/phases.d.ts +10 -0
  88. package/dist/scaffold/phases.d.ts.map +1 -0
  89. package/dist/scaffold/phases.js +165 -0
  90. package/dist/scaffold/phases.js.map +1 -0
  91. package/dist/scaffold/repomap.d.ts +6 -0
  92. package/dist/scaffold/repomap.d.ts.map +1 -0
  93. package/dist/scaffold/repomap.js +89 -0
  94. package/dist/scaffold/repomap.js.map +1 -0
  95. package/dist/scaffold/standard.d.ts +13 -0
  96. package/dist/scaffold/standard.d.ts.map +1 -0
  97. package/dist/scaffold/standard.js +371 -0
  98. package/dist/scaffold/standard.js.map +1 -0
  99. package/dist/scaffold/tokens.d.ts +3 -0
  100. package/dist/scaffold/tokens.d.ts.map +1 -0
  101. package/dist/scaffold/tokens.js +126 -0
  102. package/dist/scaffold/tokens.js.map +1 -0
  103. package/dist/server/manager.d.ts +23 -0
  104. package/dist/server/manager.d.ts.map +1 -0
  105. package/dist/server/manager.js +96 -0
  106. package/dist/server/manager.js.map +1 -0
  107. package/dist/utils/ai-trace.d.ts +8 -0
  108. package/dist/utils/ai-trace.d.ts.map +1 -0
  109. package/dist/utils/ai-trace.js +69 -0
  110. package/dist/utils/ai-trace.js.map +1 -0
  111. package/dist/utils/browser.d.ts +10 -0
  112. package/dist/utils/browser.d.ts.map +1 -0
  113. package/dist/utils/browser.js +127 -0
  114. package/dist/utils/browser.js.map +1 -0
  115. package/dist/utils/fs.d.ts +11 -0
  116. package/dist/utils/fs.d.ts.map +1 -0
  117. package/dist/utils/fs.js +166 -0
  118. package/dist/utils/fs.js.map +1 -0
  119. package/dist/utils/git.d.ts +9 -0
  120. package/dist/utils/git.d.ts.map +1 -0
  121. package/dist/utils/git.js +69 -0
  122. package/dist/utils/git.js.map +1 -0
  123. package/dist/utils/process.d.ts +21 -0
  124. package/dist/utils/process.d.ts.map +1 -0
  125. package/dist/utils/process.js +120 -0
  126. package/dist/utils/process.js.map +1 -0
  127. package/dist/version.d.ts +21 -36
  128. package/dist/version.d.ts.map +1 -1
  129. package/dist/version.js +21 -44
  130. package/dist/version.js.map +1 -1
  131. package/package.json +19 -38
  132. package/CHANGELOG.md +0 -30
  133. package/dist/commands/cce.d.ts +0 -7
  134. package/dist/commands/cce.d.ts.map +0 -1
  135. package/dist/commands/cce.js +0 -37
  136. package/dist/commands/cce.js.map +0 -1
  137. package/dist/commands/logs.d.ts +0 -3
  138. package/dist/commands/logs.d.ts.map +0 -1
  139. package/dist/commands/logs.js +0 -39
  140. package/dist/commands/logs.js.map +0 -1
  141. package/dist/commands/qa.d.ts +0 -12
  142. package/dist/commands/qa.d.ts.map +0 -1
  143. package/dist/commands/qa.js +0 -143
  144. package/dist/commands/qa.js.map +0 -1
  145. package/dist/commands/stats.d.ts +0 -37
  146. package/dist/commands/stats.d.ts.map +0 -1
  147. package/dist/commands/stats.js +0 -169
  148. package/dist/commands/stats.js.map +0 -1
  149. package/dist/config/project.d.ts +0 -51
  150. package/dist/config/project.d.ts.map +0 -1
  151. package/dist/config/project.js +0 -81
  152. package/dist/config/project.js.map +0 -1
  153. package/dist/context/cce.d.ts +0 -39
  154. package/dist/context/cce.d.ts.map +0 -1
  155. package/dist/context/cce.js +0 -455
  156. package/dist/context/cce.js.map +0 -1
  157. package/dist/governor/fingerprint.d.ts +0 -7
  158. package/dist/governor/fingerprint.d.ts.map +0 -1
  159. package/dist/governor/fingerprint.js +0 -129
  160. package/dist/governor/fingerprint.js.map +0 -1
  161. package/dist/governor/hook.d.ts +0 -13
  162. package/dist/governor/hook.d.ts.map +0 -1
  163. package/dist/governor/hook.js +0 -389
  164. package/dist/governor/hook.js.map +0 -1
  165. package/dist/governor/state.d.ts +0 -45
  166. package/dist/governor/state.d.ts.map +0 -1
  167. package/dist/governor/state.js +0 -60
  168. package/dist/governor/state.js.map +0 -1
  169. package/dist/integrations/antigravity.d.ts +0 -20
  170. package/dist/integrations/antigravity.d.ts.map +0 -1
  171. package/dist/integrations/antigravity.js +0 -153
  172. package/dist/integrations/antigravity.js.map +0 -1
  173. package/dist/output.d.ts +0 -5
  174. package/dist/output.d.ts.map +0 -1
  175. package/dist/output.js +0 -2
  176. package/dist/output.js.map +0 -1
  177. package/dist/platform/executable.d.ts +0 -11
  178. package/dist/platform/executable.d.ts.map +0 -1
  179. package/dist/platform/executable.js +0 -69
  180. package/dist/platform/executable.js.map +0 -1
  181. package/dist/platform/process.d.ts +0 -25
  182. package/dist/platform/process.d.ts.map +0 -1
  183. package/dist/platform/process.js +0 -179
  184. package/dist/platform/process.js.map +0 -1
  185. package/dist/progress.d.ts +0 -44
  186. package/dist/progress.d.ts.map +0 -1
  187. package/dist/progress.js +0 -123
  188. package/dist/progress.js.map +0 -1
  189. package/dist/qa/diff.d.ts +0 -10
  190. package/dist/qa/diff.d.ts.map +0 -1
  191. package/dist/qa/diff.js +0 -91
  192. package/dist/qa/diff.js.map +0 -1
  193. package/dist/qa/server.d.ts +0 -10
  194. package/dist/qa/server.d.ts.map +0 -1
  195. package/dist/qa/server.js +0 -127
  196. package/dist/qa/server.js.map +0 -1
  197. package/dist/scaffold/adoption.d.ts +0 -17
  198. package/dist/scaffold/adoption.d.ts.map +0 -1
  199. package/dist/scaffold/adoption.js +0 -279
  200. package/dist/scaffold/adoption.js.map +0 -1
  201. package/dist/scaffold/legacy.d.ts +0 -4
  202. package/dist/scaffold/legacy.d.ts.map +0 -1
  203. package/dist/scaffold/legacy.js +0 -139
  204. package/dist/scaffold/legacy.js.map +0 -1
  205. package/dist/scaffold/routes.d.ts +0 -3
  206. package/dist/scaffold/routes.d.ts.map +0 -1
  207. package/dist/scaffold/routes.js +0 -37
  208. package/dist/scaffold/routes.js.map +0 -1
  209. package/dist/scaffold/standalone.d.ts +0 -7
  210. package/dist/scaffold/standalone.d.ts.map +0 -1
  211. package/dist/scaffold/standalone.js +0 -294
  212. package/dist/scaffold/standalone.js.map +0 -1
  213. package/dist/scaffold/templates.d.ts +0 -8
  214. package/dist/scaffold/templates.d.ts.map +0 -1
  215. package/dist/scaffold/templates.js +0 -312
  216. package/dist/scaffold/templates.js.map +0 -1
  217. package/dist/scaffold/write.d.ts +0 -2
  218. package/dist/scaffold/write.d.ts.map +0 -1
  219. package/dist/scaffold/write.js +0 -38
  220. package/dist/scaffold/write.js.map +0 -1
  221. package/dist/telemetry/store.d.ts +0 -33
  222. package/dist/telemetry/store.d.ts.map +0 -1
  223. package/dist/telemetry/store.js +0 -75
  224. package/dist/telemetry/store.js.map +0 -1
  225. package/dist/verify/runner.d.ts +0 -15
  226. package/dist/verify/runner.d.ts.map +0 -1
  227. package/dist/verify/runner.js +0 -112
  228. package/dist/verify/runner.js.map +0 -1
  229. package/docs/agent-audit.md +0 -24
package/dist/qa/runner.js CHANGED
@@ -1,368 +1,318 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
1
+ import fs from "node:fs/promises";
2
+ import http from "node:http";
2
3
  import path from "node:path";
3
- import { chromium } from "playwright";
4
- import { updateTelemetry } from "../telemetry/store.js";
5
- import { compareVisualArtifact } from "./diff.js";
6
- import { acquireServer } from "./server.js";
7
- export function formatVisualDiff(diff) {
8
- const percent = (diff.ratio * 100).toFixed(2);
9
- return `${diff.viewport.padEnd(11)} ${diff.route.padEnd(12)} ${percent}% changed (${diff.changedPixels}/${diff.totalPixels} px) -> ${diff.diffPath}`;
10
- }
11
- function label(viewport) {
12
- return `${viewport.width}x${viewport.height}`;
13
- }
14
- export function visualArtifactSlug(route) {
15
- if (route === "/")
16
- return "index";
17
- const slug = route
18
- .replace(/^\/+/, "")
19
- .replaceAll("/", "-")
20
- .replace(/[^a-zA-Z0-9._-]+/g, "-")
21
- .replace(/-+/g, "-")
22
- .replace(/^-+|-+$/g, "");
23
- return slug || "route";
24
- }
25
- async function inspectPage(page, baseUrl, route, viewport) {
26
- const issues = [];
27
- let probes = 0;
28
- const geometry = await page.evaluate(() => {
29
- const root = document.documentElement;
30
- const overflow = Math.max(root.scrollWidth, document.body?.scrollWidth ?? 0) -
31
- window.innerWidth;
32
- const offenders = [...document.querySelectorAll("body *")]
33
- .filter((element) => element.getBoundingClientRect().right > window.innerWidth + 1)
34
- .slice(0, 5)
35
- .map((element) => ({
36
- selector: element.id
37
- ? `#${element.id}`
38
- : element.classList.length
39
- ? `${element.tagName.toLowerCase()}.${[...element.classList].join(".")}`
40
- : element.tagName.toLowerCase(),
41
- pixels: Math.ceil(element.getBoundingClientRect().right - window.innerWidth),
42
- }));
43
- const unnamed = [
44
- ...document.querySelectorAll("button, input, select, textarea, [role=button], [role=link]"),
45
- ]
46
- .filter((element) => {
47
- const text = element.getAttribute("aria-label") ||
48
- element.getAttribute("aria-labelledby") ||
49
- element.getAttribute("title") ||
50
- element.textContent?.trim() ||
51
- element.name;
52
- return !text && !element.hasAttribute("disabled");
53
- })
54
- .slice(0, 5)
55
- .map((element) => element.outerHTML.slice(0, 120));
56
- const hiddenImportant = [
57
- ...document.querySelectorAll("[data-mozole-important]"),
58
- ]
59
- .filter((element) => {
60
- const box = element.getBoundingClientRect();
61
- const style = getComputedStyle(element);
62
- return (box.width === 0 ||
63
- box.height === 0 ||
64
- style.visibility === "hidden" ||
65
- style.display === "none");
66
- })
67
- .map((element) => element.id || element.className || element.tagName.toLowerCase());
68
- return {
69
- overflow,
70
- offenders,
71
- unnamed,
72
- hiddenImportant,
73
- title: document.title,
74
- lang: document.documentElement.lang,
75
- headings: document.querySelectorAll("h1").length,
76
- };
77
- });
78
- probes += 6;
79
- if (geometry.overflow > 1)
80
- issues.push({
81
- viewport,
82
- route,
83
- kind: "overflow",
84
- evidence: `${geometry.offenders[0]?.selector ?? "document"}: horizontal overflow ${Math.ceil(geometry.overflow)}px`,
85
- });
86
- if (!geometry.title)
87
- issues.push({
88
- viewport,
89
- route,
90
- kind: "accessibility",
91
- evidence: "document title is empty",
92
- });
93
- if (!geometry.lang)
94
- issues.push({
95
- viewport,
96
- route,
97
- kind: "accessibility",
98
- evidence: "html lang is missing",
99
- });
100
- if (!geometry.headings)
101
- issues.push({
102
- viewport,
103
- route,
104
- kind: "accessibility",
105
- evidence: "page has no h1",
106
- });
107
- if (geometry.unnamed.length)
108
- issues.push({
109
- viewport,
110
- route,
111
- kind: "accessibility",
112
- evidence: `unnamed interactive control: ${geometry.unnamed[0]}`,
113
- });
114
- for (const element of geometry.hiddenImportant)
115
- issues.push({
116
- viewport,
117
- route,
118
- kind: "visibility",
119
- evidence: `${element}: visible false`,
120
- });
121
- const links = await page.locator("a[href]").evaluateAll((nodes) => nodes
122
- .map((node) => node.href)
123
- .filter((href) => {
4
+ import pc from "picocolors";
5
+ import { chromium } from "playwright-core";
6
+ import { findAvailablePort } from "../server/manager.js";
7
+ import { ensureChromiumBrowser, getBrowserLaunchArgs } from "../utils/browser.js";
8
+ import { exists, findProjectRoot } from "../utils/fs.js";
9
+ import { extractBreakpointBoundaries } from "./breakpoints.js";
10
+ import { probeDOM } from "./probe.js";
11
+ const MIME_TYPES = {
12
+ ".html": "text/html; charset=utf-8",
13
+ ".css": "text/css; charset=utf-8",
14
+ ".js": "application/javascript; charset=utf-8",
15
+ ".mjs": "application/javascript; charset=utf-8",
16
+ ".json": "application/json; charset=utf-8",
17
+ ".svg": "image/svg+xml",
18
+ ".png": "image/png",
19
+ ".jpg": "image/jpeg",
20
+ ".jpeg": "image/jpeg",
21
+ ".webp": "image/webp",
22
+ ".woff2": "font/woff2",
23
+ ".woff": "font/woff",
24
+ ".ico": "image/x-icon",
25
+ };
26
+ async function createStaticServer(rootDirectory, port) {
27
+ const realRoot = await fs.realpath(rootDirectory).catch(() => path.resolve(rootDirectory));
28
+ const realRootPrefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
29
+ const server = http.createServer(async (req, res) => {
124
30
  try {
125
- const url = new URL(href);
126
- return (url.origin === location.origin &&
127
- ["http:", "https:"].includes(url.protocol));
31
+ const parsedUrl = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
32
+ let reqPath;
33
+ try {
34
+ reqPath = decodeURIComponent(parsedUrl.pathname);
35
+ }
36
+ catch {
37
+ res.writeHead(400, { "Content-Type": "text/plain" });
38
+ res.end("Bad Request");
39
+ return;
40
+ }
41
+ const normalizedRoot = path.resolve(rootDirectory);
42
+ let filePath = path.resolve(normalizedRoot, `.${path.normalize(reqPath)}`);
43
+ // Prevent directory traversal attacks escaping rootDirectory
44
+ if (!filePath.startsWith(normalizedRoot + path.sep) && filePath !== normalizedRoot) {
45
+ res.writeHead(403, { "Content-Type": "text/plain" });
46
+ res.end("Forbidden: Access denied outside web root");
47
+ return;
48
+ }
49
+ let stat = await fs.stat(filePath).catch(() => null);
50
+ if (stat?.isDirectory()) {
51
+ filePath = path.join(filePath, "index.html");
52
+ stat = await fs.stat(filePath).catch(() => null);
53
+ }
54
+ // Route fallback: /about -> /about.html
55
+ if (!stat && !path.extname(reqPath)) {
56
+ const withHtml = path.resolve(normalizedRoot, `.${path.normalize(reqPath)}.html`);
57
+ if (withHtml.startsWith(normalizedRoot + path.sep)) {
58
+ stat = await fs.stat(withHtml).catch(() => null);
59
+ if (stat)
60
+ filePath = withHtml;
61
+ }
62
+ }
63
+ // SPA fallback to index.html
64
+ if (!stat) {
65
+ const spaFallback = path.join(normalizedRoot, "index.html");
66
+ stat = await fs.stat(spaFallback).catch(() => null);
67
+ if (stat)
68
+ filePath = spaFallback;
69
+ }
70
+ if (!stat || !stat.isFile()) {
71
+ res.writeHead(404, { "Content-Type": "text/plain" });
72
+ res.end("Not Found");
73
+ return;
74
+ }
75
+ const realTarget = await fs.realpath(filePath).catch(() => null);
76
+ if (!realTarget || (realTarget !== realRoot && !realTarget.startsWith(realRootPrefix))) {
77
+ res.writeHead(403, { "Content-Type": "text/plain" });
78
+ res.end("Forbidden: Access denied outside web root");
79
+ return;
80
+ }
81
+ const ext = path.extname(filePath).toLowerCase();
82
+ const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
83
+ const content = await fs.readFile(filePath);
84
+ res.writeHead(200, {
85
+ "Content-Type": contentType,
86
+ "Content-Length": content.length,
87
+ "Cache-Control": "no-cache",
88
+ });
89
+ res.end(content);
128
90
  }
129
91
  catch {
130
- return false;
92
+ res.writeHead(500, { "Content-Type": "text/plain" });
93
+ res.end("Internal Server Error");
131
94
  }
132
- }));
133
- const unique = [...new Set(links)].slice(0, 100);
134
- probes += unique.length;
135
- const chunkSize = 5;
136
- for (let i = 0; i < unique.length; i += chunkSize) {
137
- const chunk = unique.slice(i, i + chunkSize);
138
- await Promise.all(chunk.map(async (href) => {
139
- try {
140
- const response = await page.request.get(href, { timeout: 5_000 });
141
- if (response.status() >= 400) {
142
- let pathname = href;
143
- try {
144
- pathname = new URL(href).pathname;
145
- }
146
- catch { }
147
- issues.push({
148
- viewport,
149
- route,
150
- kind: "broken-link",
151
- evidence: `${pathname}: HTTP ${response.status()}`,
152
- });
95
+ });
96
+ await new Promise((resolve, reject) => {
97
+ server.listen(port, "127.0.0.1", () => resolve());
98
+ server.once("error", reject);
99
+ });
100
+ return {
101
+ serverUrl: `http://127.0.0.1:${port}`,
102
+ close: () => new Promise((resolve) => {
103
+ server.close(() => resolve());
104
+ }),
105
+ };
106
+ }
107
+ async function discoverRoutesAndDirectory(projectRoot, explicitStaticDir) {
108
+ let staticDir = explicitStaticDir;
109
+ if (!staticDir) {
110
+ const candidates = [
111
+ path.join(projectRoot, "build", "client"),
112
+ path.join(projectRoot, "dist"),
113
+ projectRoot,
114
+ ];
115
+ for (const dir of candidates) {
116
+ if (await exists(dir)) {
117
+ const indexHtml = path.join(dir, "index.html");
118
+ if (await exists(indexHtml)) {
119
+ staticDir = dir;
120
+ break;
153
121
  }
154
122
  }
155
- catch {
156
- let pathname = href;
157
- try {
158
- pathname = new URL(href, baseUrl).pathname;
123
+ }
124
+ }
125
+ if (!staticDir || !(await exists(staticDir))) {
126
+ throw new Error(`No built static directory found in ${projectRoot}. Run 'npm run build' before running live DOM probe.`);
127
+ }
128
+ const routes = new Set(["/"]);
129
+ async function scanHtmlRoutes(dir, base) {
130
+ if (!(await exists(dir)))
131
+ return;
132
+ const entries = await fs.readdir(dir, { withFileTypes: true });
133
+ for (const entry of entries) {
134
+ const full = path.join(dir, entry.name);
135
+ if (entry.isDirectory()) {
136
+ await scanHtmlRoutes(full, `${base}/${entry.name}`);
137
+ }
138
+ else if (entry.isFile() && entry.name.endsWith(".html")) {
139
+ if (entry.name === "__spa-fallback.html")
140
+ continue;
141
+ if (entry.name === "index.html") {
142
+ routes.add(base || "/");
143
+ }
144
+ else {
145
+ const routeName = entry.name.replace(/\.html$/, "");
146
+ routes.add(`${base}/${routeName}`);
159
147
  }
160
- catch { }
161
- issues.push({
162
- viewport,
163
- route,
164
- kind: "broken-link",
165
- evidence: `${pathname}: request failed`,
166
- });
167
148
  }
168
- }));
149
+ }
169
150
  }
170
- return { issues, probes };
151
+ await scanHtmlRoutes(staticDir, "");
152
+ return { staticDir, routes: Array.from(routes) };
171
153
  }
172
- async function interactionProbes(page, viewport, route) {
173
- const issues = [];
174
- let probes = 0;
175
- const details = page.locator("details");
176
- for (let index = 0; index < Math.min(await details.count(), 10); index++) {
177
- await details.nth(index).locator("summary").click();
178
- probes++;
179
- if (!(await details
180
- .nth(index)
181
- .evaluate((node) => node.open)))
182
- issues.push({
183
- viewport,
184
- route,
185
- kind: "interaction",
186
- evidence: `details[${index}] did not open`,
187
- });
188
- }
189
- const safe = page.locator("[data-mozole-qa=click]");
190
- for (let index = 0; index < Math.min(await safe.count(), 10); index++) {
191
- try {
192
- await safe.nth(index).click({ timeout: 2_000 });
193
- probes++;
154
+ export async function runLiveGeometryProbe(options = {}) {
155
+ const startTime = Date.now();
156
+ const cwd = options.cwd ?? process.cwd();
157
+ const projectRoot = (await findProjectRoot(cwd)) ?? cwd;
158
+ console.log(pc.cyan(`\n🌐 Launching Headless DOM Geometry & Trace Runner in ${projectRoot}...`));
159
+ // 1. Detect or provision Chromium/Brave
160
+ const browserInfo = await ensureChromiumBrowser();
161
+ console.log(pc.dim(` • Using browser engine: ${browserInfo.name}${browserInfo.version ? ` (${browserInfo.version})` : ""}`));
162
+ // 2. Discover built static files & routes
163
+ const { staticDir, routes: discoveredRoutes } = await discoverRoutesAndDirectory(projectRoot, options.staticDir);
164
+ const targetRoutes = options.routes ?? discoveredRoutes;
165
+ // 3. Extract CSS breakpoints to populate boundary viewports
166
+ const defaultViewports = [
167
+ { width: 320, height: 600, name: "Mobile Small (320px)" },
168
+ { width: 390, height: 844, name: "Mobile Standard (390px)" },
169
+ { width: 768, height: 1024, name: "Tablet (768px)" },
170
+ { width: 1280, height: 800, name: "Desktop (1280px)" },
171
+ ];
172
+ const cssList = [];
173
+ async function collectCss(dir) {
174
+ if (!(await exists(dir)))
175
+ return;
176
+ for (const e of await fs.readdir(dir, { withFileTypes: true })) {
177
+ const full = path.join(dir, e.name);
178
+ if (e.isDirectory() && !["node_modules", ".git"].includes(e.name)) {
179
+ await collectCss(full);
180
+ }
181
+ else if (e.isFile() && e.name.endsWith(".css")) {
182
+ cssList.push(await fs.readFile(full, "utf8"));
183
+ }
194
184
  }
195
- catch (error) {
196
- issues.push({
197
- viewport,
198
- route,
199
- kind: "interaction",
200
- evidence: `safe click ${index} failed: ${String(error).split("\n")[0]}`,
201
- });
185
+ }
186
+ await collectCss(staticDir);
187
+ const boundaryPoints = extractBreakpointBoundaries(cssList, { minWidth: 320, maxWidth: 1440 });
188
+ for (const bp of boundaryPoints) {
189
+ if (!defaultViewports.some((v) => v.width === bp)) {
190
+ defaultViewports.push({ width: bp, height: 800, name: `Breakpoint boundary (${bp}px)` });
202
191
  }
203
192
  }
204
- await page.keyboard.press("Tab");
205
- probes++;
206
- if (!(await page.evaluate(() => document.activeElement !== document.body)))
207
- issues.push({
208
- viewport,
209
- route,
210
- kind: "keyboard",
211
- evidence: "Tab did not move focus",
212
- });
213
- return { issues, probes };
214
- }
215
- export async function runQa(root, config, mode, signal) {
216
- const server = await acquireServer(root, config, signal);
193
+ defaultViewports.sort((a, b) => a.width - b.width);
194
+ const viewportsToTest = options.viewports ?? defaultViewports;
195
+ // 4. Start local static HTTP server
196
+ const port = options.port ?? (await findAvailablePort(4173));
197
+ const staticServer = await createStaticServer(staticDir, port);
198
+ const findings = [];
217
199
  let browser;
218
- const issues = [];
219
- const visualDiffs = [];
220
- let probes = 0;
221
- let screenshots = 0;
222
- let consoleErrors = 0;
223
- let failedRequests = 0;
200
+ let tracePath;
224
201
  try {
225
- browser = await chromium.launch({ headless: true });
226
- const artifactRoot = path.join(root, ".mozole", "qa");
227
- if (mode.visual)
228
- await mkdir(artifactRoot, { recursive: true });
229
- for (const viewport of config.qa.viewports) {
230
- signal?.throwIfAborted();
231
- const context = await browser.newContext({ viewport });
232
- const page = await context.newPage();
233
- const viewportName = label(viewport);
234
- page.on("console", (message) => {
235
- if (message.type() === "error") {
236
- consoleErrors++;
237
- issues.push({
238
- viewport: viewportName,
239
- route: page.url(),
240
- kind: "console",
241
- evidence: message.text().slice(0, 500),
242
- });
243
- }
244
- });
245
- page.on("pageerror", (error) => issues.push({
246
- viewport: viewportName,
247
- route: page.url(),
248
- kind: "runtime",
249
- evidence: error.message.slice(0, 500),
250
- }));
251
- page.on("requestfailed", (request) => {
252
- const failure = request.failure()?.errorText ?? "failed";
253
- if (!failure.includes("ERR_ABORTED")) {
254
- failedRequests++;
255
- issues.push({
256
- viewport: viewportName,
257
- route: page.url(),
258
- kind: "network",
259
- evidence: `${request.method()} ${request.url()} ${failure}`,
260
- });
202
+ browser = await chromium.launch({
203
+ executablePath: browserInfo.executablePath,
204
+ headless: true,
205
+ args: getBrowserLaunchArgs(),
206
+ });
207
+ const context = await browser.newContext({
208
+ serviceWorkers: "block",
209
+ });
210
+ // Zero-screenshot policy: snapshots = true, screenshots = false
211
+ await context.tracing.start({
212
+ screenshots: false,
213
+ snapshots: true,
214
+ sources: true,
215
+ });
216
+ const page = await context.newPage();
217
+ for (const route of targetRoutes) {
218
+ const url = `${staticServer.serverUrl}${route}`;
219
+ for (const vp of viewportsToTest) {
220
+ await page.setViewportSize({ width: vp.width, height: vp.height });
221
+ await page.goto(url, { waitUntil: "domcontentloaded" });
222
+ await page.waitForTimeout(50);
223
+ // Run live probe in browser context
224
+ const result = (await page.evaluate(probeDOM));
225
+ for (const [rule, issues] of Object.entries(result.issues)) {
226
+ for (const issue of issues) {
227
+ findings.push({
228
+ rule,
229
+ issue,
230
+ route,
231
+ viewport: vp,
232
+ });
233
+ }
261
234
  }
262
- });
263
- page.on("response", (response) => {
264
- if (response.status() >= 400) {
265
- failedRequests++;
266
- issues.push({
267
- viewport: viewportName,
268
- route: page.url(),
269
- kind: "network",
270
- evidence: `${response.request().method()} ${response.url()} HTTP ${response.status()}`,
235
+ // Stress test: 200% font size reflow check (capture sizes first to prevent compounding inheritance)
236
+ await page.evaluate(() => {
237
+ const elements = Array.from(document.querySelectorAll("body, body *"));
238
+ const sizes = elements.map((el) => Number.parseFloat(window.getComputedStyle(el).fontSize));
239
+ elements.forEach((el, i) => {
240
+ if (sizes[i] > 0) {
241
+ el.style.setProperty("font-size", `${sizes[i] * 2}px`, "important");
242
+ }
271
243
  });
272
- }
273
- });
274
- for (const route of config.routes) {
275
- signal?.throwIfAborted();
276
- const response = await page.goto(`${server.url}${route}`, {
277
- waitUntil: "networkidle",
278
- timeout: 30_000,
279
244
  });
280
- probes++;
281
- if (!response || response.status() >= 400) {
282
- issues.push({
283
- viewport: viewportName,
245
+ await page.waitForTimeout(50);
246
+ const stressResult = (await page.evaluate(probeDOM));
247
+ for (const issue of stressResult.issues.overflow) {
248
+ findings.push({
249
+ rule: "stress-overflow-200",
250
+ issue,
284
251
  route,
285
- kind: "route",
286
- evidence: `HTTP ${response?.status() ?? "no response"}`,
252
+ viewport: vp,
287
253
  });
288
- continue;
289
254
  }
290
- const inspected = await inspectPage(page, server.url, route, viewportName);
291
- issues.push(...inspected.issues);
292
- probes += inspected.probes;
293
- if (mode.interactive) {
294
- const interacted = await interactionProbes(page, viewportName, route);
295
- issues.push(...interacted.issues);
296
- probes += interacted.probes;
297
- }
298
- if (mode.visual) {
299
- const screenshotPath = path.join(artifactRoot, `${viewportName}-${visualArtifactSlug(route)}.png`);
300
- const screenshot = await page.screenshot({ fullPage: true });
301
- if (mode.diff) {
302
- const comparison = await compareVisualArtifact(screenshotPath, screenshot, config.qa.visualDiffThreshold ?? 0.01);
303
- if (comparison.exceeded && comparison.diffPath) {
304
- visualDiffs.push({
305
- viewport: viewportName,
306
- route,
307
- ratio: comparison.ratio,
308
- changedPixels: comparison.changedPixels,
309
- totalPixels: comparison.totalPixels,
310
- diffPath: path.relative(root, comparison.diffPath),
311
- });
312
- }
313
- }
314
- else {
315
- await writeFile(screenshotPath, screenshot);
316
- }
317
- screenshots++;
255
+ for (const issue of stressResult.issues.clipping) {
256
+ findings.push({
257
+ rule: "stress-clipping-200",
258
+ issue,
259
+ route,
260
+ viewport: vp,
261
+ });
318
262
  }
319
263
  }
320
- await context.close();
321
264
  }
265
+ if (findings.length > 0 && options.saveTraceOnFailure !== false) {
266
+ const traceDir = path.join(projectRoot, "docs", "qa");
267
+ await fs.mkdir(traceDir, { recursive: true });
268
+ tracePath = options.tracePath ?? path.join(traceDir, "trace.zip");
269
+ await context.tracing.stop({ path: tracePath });
270
+ }
271
+ else {
272
+ await context.tracing.stop();
273
+ }
274
+ await context.close();
322
275
  }
323
276
  finally {
324
- await browser?.close();
325
- await server.stop();
277
+ if (browser) {
278
+ await browser.close().catch(() => { });
279
+ }
280
+ await staticServer.close().catch(() => { });
281
+ }
282
+ const durationMs = Date.now() - startTime;
283
+ if (findings.length > 0) {
284
+ console.log(pc.red(`\n ✗ Found ${findings.length} live DOM geometry / accessibility defect(s) during probe:`));
285
+ for (const f of findings.slice(0, 15)) {
286
+ console.log(pc.yellow(` [${f.rule.toUpperCase()}] ${f.route} @ ${f.viewport.width}px (${f.viewport.name ?? ""})`));
287
+ console.log(pc.dim(` Selector: ${f.issue.selector}`));
288
+ if (f.issue.problem)
289
+ console.log(pc.dim(` Problem: ${f.issue.problem}`));
290
+ if (f.issue.text)
291
+ console.log(pc.dim(` Text: "${f.issue.text}"`));
292
+ if (f.issue.width || f.issue.height) {
293
+ console.log(pc.dim(` Size: ${f.issue.width ?? 0}x${f.issue.height ?? 0}px`));
294
+ }
295
+ }
296
+ if (findings.length > 15) {
297
+ console.log(pc.dim(` ... and ${findings.length - 15} more findings.`));
298
+ }
299
+ if (tracePath) {
300
+ console.log(pc.cyan(`\n 📦 Trace recording saved to: ${tracePath}`));
301
+ console.log(pc.dim(" Inspect trace online at https://trace.playwright.dev (zero screenshots, full DOM snapshots & network)."));
302
+ }
303
+ }
304
+ else {
305
+ console.log(pc.green(` ✓ Live DOM geometry verified across ${targetRoutes.length} route(s) and ${viewportsToTest.length} viewport(s) with zero defects.`));
306
+ console.log(pc.dim(` • Verified in ${durationMs}ms with zero raster screenshots.`));
326
307
  }
327
- const unique = [
328
- ...new Map(issues.map((issue) => [
329
- `${issue.viewport}|${issue.route}|${issue.kind}|${issue.evidence}`,
330
- issue,
331
- ])).values(),
332
- ];
333
- const passed = unique.length === 0;
334
- const diffOutput = mode.diff
335
- ? visualDiffs.length
336
- ? `\n\nVISUAL DIFFS\n${visualDiffs.map(formatVisualDiff).join("\n")}`
337
- : "\n\nVISUAL DIFFS\nNo changes above threshold."
338
- : "";
339
- const output = (passed
340
- ? `QA PASS\n\n${config.qa.viewports.map((viewport) => `${label(viewport).padEnd(11)} ✓`).join("\n")}\n\nconsole errors ${consoleErrors}\nfailed requests ${failedRequests}\noverflow ${unique.filter((issue) => issue.kind === "overflow").length}\nbroken links ${unique.filter((issue) => issue.kind === "broken-link").length}${mode.visual ? `\nscreenshots ${screenshots}` : ""}`
341
- : `QA FAIL\n\n${unique
342
- .slice(0, 12)
343
- .map((issue) => `${issue.viewport} ${issue.route}\n${issue.evidence}`)
344
- .join("\n\n")}`) + diffOutput;
345
- if (config.telemetry?.enabled)
346
- await updateTelemetry(root, (telemetry) => {
347
- telemetry.qa.runs++;
348
- telemetry.qa.probes += probes;
349
- telemetry.qa.screenshots += screenshots;
350
- if (mode.visual)
351
- telemetry.qa.visualRuns++;
352
- telemetry.governor.modelFacingDiagnosticChars += output.length;
353
- });
354
308
  return {
355
- passed,
356
- issues: unique,
357
- viewports: config.qa.viewports.map(label),
358
- consoleErrors,
359
- failedRequests,
360
- overflows: unique.filter((issue) => issue.kind === "overflow").length,
361
- brokenLinks: unique.filter((issue) => issue.kind === "broken-link").length,
362
- probes,
363
- screenshots,
364
- visualDiffs,
365
- output,
309
+ success: findings.length === 0,
310
+ browserName: browserInfo.name,
311
+ findings,
312
+ routesChecked: targetRoutes,
313
+ viewportsChecked: viewportsToTest,
314
+ durationMs,
315
+ traceSavedTo: tracePath,
366
316
  };
367
317
  }
368
318
  //# sourceMappingURL=runner.js.map