comber 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +732 -0
  3. package/action.yml +279 -0
  4. package/comber.config.example.json +64 -0
  5. package/dist/api/classify.js +99 -0
  6. package/dist/api/classify.js.map +1 -0
  7. package/dist/api/driver.js +235 -0
  8. package/dist/api/driver.js.map +1 -0
  9. package/dist/api/graphql-driver.js +150 -0
  10. package/dist/api/graphql-driver.js.map +1 -0
  11. package/dist/api/graphql.js +115 -0
  12. package/dist/api/graphql.js.map +1 -0
  13. package/dist/api/har-driver.js +167 -0
  14. package/dist/api/har-driver.js.map +1 -0
  15. package/dist/api/har.js +100 -0
  16. package/dist/api/har.js.map +1 -0
  17. package/dist/api/load.js +83 -0
  18. package/dist/api/load.js.map +1 -0
  19. package/dist/api/openapi.js +159 -0
  20. package/dist/api/openapi.js.map +1 -0
  21. package/dist/api/request.js +141 -0
  22. package/dist/api/request.js.map +1 -0
  23. package/dist/api/schema.js +126 -0
  24. package/dist/api/schema.js.map +1 -0
  25. package/dist/cli.js +352 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/comment.js +164 -0
  28. package/dist/comment.js.map +1 -0
  29. package/dist/config.js +664 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/connectors/builtin/discord.js +55 -0
  32. package/dist/connectors/builtin/github-checks.js +53 -0
  33. package/dist/connectors/builtin/junit.js +70 -0
  34. package/dist/connectors/builtin/linear.js +58 -0
  35. package/dist/connectors/builtin/slack.js +60 -0
  36. package/dist/connectors/builtin/webhook.js +39 -0
  37. package/dist/connectors/index.js +3 -0
  38. package/dist/connectors/loader.js +72 -0
  39. package/dist/connectors/registry.js +28 -0
  40. package/dist/connectors/types.js +23 -0
  41. package/dist/core/action-gate.js +48 -0
  42. package/dist/core/baseline.js +85 -0
  43. package/dist/core/baseline.js.map +1 -0
  44. package/dist/core/blind-spots.js +22 -0
  45. package/dist/core/blind-spots.js.map +1 -0
  46. package/dist/core/contract-extras.js +1 -0
  47. package/dist/core/contract-extras.js.map +1 -0
  48. package/dist/core/contract.js +1 -0
  49. package/dist/core/contract.js.map +1 -0
  50. package/dist/core/coverage.js +70 -0
  51. package/dist/core/coverage.js.map +1 -0
  52. package/dist/core/crawl.js +229 -0
  53. package/dist/core/crawl.js.map +1 -0
  54. package/dist/core/egress.js +128 -0
  55. package/dist/core/egress.js.map +1 -0
  56. package/dist/core/ledger.js +41 -0
  57. package/dist/core/ledger.js.map +1 -0
  58. package/dist/core/net.js +39 -0
  59. package/dist/core/pipeline.js +191 -0
  60. package/dist/core/pipeline.js.map +1 -0
  61. package/dist/core/plan.js +121 -0
  62. package/dist/core/plan.js.map +1 -0
  63. package/dist/core/redact.js +240 -0
  64. package/dist/core/redact.js.map +1 -0
  65. package/dist/core/replay.js +263 -0
  66. package/dist/core/replay.js.map +1 -0
  67. package/dist/core/report.js +253 -0
  68. package/dist/core/report.js.map +1 -0
  69. package/dist/core/safety-judge.js +116 -0
  70. package/dist/core/safety.js +92 -0
  71. package/dist/core/safety.js.map +1 -0
  72. package/dist/core/signal-normalize.js +190 -0
  73. package/dist/core/signal-normalize.js.map +1 -0
  74. package/dist/core/signals.js +5 -0
  75. package/dist/core/signals.js.map +1 -0
  76. package/dist/core/triage.js +104 -0
  77. package/dist/core/triage.js.map +1 -0
  78. package/dist/core/types.js +1 -0
  79. package/dist/core/types.js.map +1 -0
  80. package/dist/desktop/desktop-profile.js +6 -0
  81. package/dist/desktop/desktop-profile.js.map +1 -0
  82. package/dist/desktop/driver.js +433 -0
  83. package/dist/desktop/driver.js.map +1 -0
  84. package/dist/desktop/evidence.js +206 -0
  85. package/dist/desktop/evidence.js.map +1 -0
  86. package/dist/desktop/macos/accessibility-runner.js +118 -0
  87. package/dist/desktop/macos/accessibility-runner.js.map +1 -0
  88. package/dist/desktop/macos/types.js +1 -0
  89. package/dist/desktop/macos/types.js.map +1 -0
  90. package/dist/desktop/preflight.js +107 -0
  91. package/dist/desktop/preflight.js.map +1 -0
  92. package/dist/desktop/proxy-proof.js +185 -0
  93. package/dist/desktop/proxy-proof.js.map +1 -0
  94. package/dist/desktop/view.js +348 -0
  95. package/dist/desktop/view.js.map +1 -0
  96. package/dist/desktop/windows/types.js +1 -0
  97. package/dist/desktop/windows/types.js.map +1 -0
  98. package/dist/desktop/windows/uia-runner.js +127 -0
  99. package/dist/desktop/windows/uia-runner.js.map +1 -0
  100. package/dist/native/apple.js +14 -0
  101. package/dist/native/apple.js.map +1 -0
  102. package/dist/native/device-profile.js +6 -0
  103. package/dist/native/device-profile.js.map +1 -0
  104. package/dist/native/driver.js +416 -0
  105. package/dist/native/driver.js.map +1 -0
  106. package/dist/native/evidence.js +164 -0
  107. package/dist/native/evidence.js.map +1 -0
  108. package/dist/native/load.js +9 -0
  109. package/dist/native/load.js.map +1 -0
  110. package/dist/native/maestro.js +153 -0
  111. package/dist/native/maestro.js.map +1 -0
  112. package/dist/native/preflight.js +72 -0
  113. package/dist/native/preflight.js.map +1 -0
  114. package/dist/native/proxy-proof.js +182 -0
  115. package/dist/native/proxy-proof.js.map +1 -0
  116. package/dist/native/samsung.js +34 -0
  117. package/dist/native/samsung.js.map +1 -0
  118. package/dist/native/signals.js +50 -0
  119. package/dist/native/signals.js.map +1 -0
  120. package/dist/native/view.js +139 -0
  121. package/dist/native/view.js.map +1 -0
  122. package/dist/profile.js +237 -0
  123. package/dist/profile.js.map +1 -0
  124. package/dist/sink.js +134 -0
  125. package/dist/sink.js.map +1 -0
  126. package/dist/sinks/file.js +49 -0
  127. package/dist/sinks/file.js.map +1 -0
  128. package/dist/sinks/http.js +38 -0
  129. package/dist/sinks/http.js.map +1 -0
  130. package/dist/sinks/index.js +5 -0
  131. package/dist/sinks/index.js.map +1 -0
  132. package/dist/sinks/multi.js +40 -0
  133. package/dist/sinks/multi.js.map +1 -0
  134. package/dist/sinks/paths.js +32 -0
  135. package/dist/sinks/paths.js.map +1 -0
  136. package/dist/sinks/record.js +58 -0
  137. package/dist/sinks/record.js.map +1 -0
  138. package/dist/sinks/types.js +1 -0
  139. package/dist/sinks/types.js.map +1 -0
  140. package/dist/web/breakage.js +210 -0
  141. package/dist/web/breakage.js.map +1 -0
  142. package/dist/web/controls.js +102 -0
  143. package/dist/web/controls.js.map +1 -0
  144. package/dist/web/driver.js +803 -0
  145. package/dist/web/driver.js.map +1 -0
  146. package/dist/web/elements.js +151 -0
  147. package/dist/web/elements.js.map +1 -0
  148. package/dist/web/fingerprint.js +13 -0
  149. package/dist/web/fingerprint.js.map +1 -0
  150. package/dist/web/forms.js +74 -0
  151. package/dist/web/forms.js.map +1 -0
  152. package/dist/web/links.js +194 -0
  153. package/dist/web/links.js.map +1 -0
  154. package/dist/web/types.js +1 -0
  155. package/dist/web/types.js.map +1 -0
  156. package/package.json +80 -0
  157. package/runtime/macos-accessibility-runner.js +275 -0
  158. package/runtime/windows-uia-runner.ps1 +539 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/sinks/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAE,QAAQ,EAAwB,MAAM,WAAW,CAAC;AAC3D,OAAO,EAAE,QAAQ,EAAwB,MAAM,WAAW,CAAC;AAC3D,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC"}
@@ -0,0 +1,40 @@
1
+ import { FileSink } from "./file.js";
2
+ import { HttpSink } from "./http.js";
3
+ /**
4
+ * Fan-out sink. Opt-in via env; an empty MultiSink is a no-op so env-unset runs stay
5
+ * byte-identical to local-only today. Each sink is error-swallowed individually —
6
+ * persistence NEVER breaks a run. All adapters are generic and dependency-light; no
7
+ * vendor/internal coupling lives in the baseline (a RADLAB overlay layers those on via
8
+ * its own Sink implementations).
9
+ *
10
+ * Env wiring:
11
+ * COMBER_SINK_FILE → FileSink JSONL path (one redacted run-summary record per line)
12
+ * COMBER_SINK_DIR → FileSink screenshot output directory (per-run subfolder)
13
+ * COMBER_SINK_URL → HttpSink webhook URL (POST redacted run record)
14
+ * COMBER_SINK_TOKEN → HttpSink Bearer token (header only; never logged, never in body)
15
+ */
16
+ export class MultiSink {
17
+ sinks;
18
+ constructor(sinks) {
19
+ this.sinks = sinks;
20
+ }
21
+ static fromEnv(env = process.env) {
22
+ const sinks = [];
23
+ const file = env.COMBER_SINK_FILE?.trim();
24
+ const dir = env.COMBER_SINK_DIR?.trim();
25
+ if (file || dir) {
26
+ sinks.push(new FileSink({ filePath: file || undefined, screenshotDir: dir || undefined }));
27
+ }
28
+ const url = env.COMBER_SINK_URL?.trim();
29
+ if (url) {
30
+ sinks.push(new HttpSink({ url, token: env.COMBER_SINK_TOKEN?.trim() || undefined }));
31
+ }
32
+ return new MultiSink(sinks);
33
+ }
34
+ get active() {
35
+ return this.sinks.map((s) => s.name);
36
+ }
37
+ async recordRun(result, meta) {
38
+ await Promise.all(this.sinks.map((s) => s.recordRun(result, meta).catch((e) => console.error(`[sink:${s.name}] ${String(e).slice(0, 160)}`))));
39
+ }
40
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"multi.js","sourceRoot":"","sources":["../../src/sinks/multi.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAErC;;;;;;;;;;;;GAYG;AACH,MAAM,OAAO,SAAS;IACA;IAApB,YAAoB,KAAa;QAAb,UAAK,GAAL,KAAK,CAAQ;IAAG,CAAC;IAErC,MAAM,CAAC,OAAO,CAAC,MAAyB,OAAO,CAAC,GAAG;QACjD,MAAM,KAAK,GAAW,EAAE,CAAC;QAEzB,MAAM,IAAI,GAAG,GAAG,CAAC,gBAAgB,EAAE,IAAI,EAAE,CAAC;QAC1C,MAAM,GAAG,GAAG,GAAG,CAAC,eAAe,EAAE,IAAI,EAAE,CAAC;QACxC,IAAI,IAAI,IAAI,GAAG,EAAE,CAAC;YAChB,KAAK,CAAC,IAAI,CAAC,IAAI,QAAQ,CAAC,EAAE,QAAQ,EAAE,IAAI,IAAI,SAAS,EAAE,aAAa,EAAE,GAAG,IAAI,SAAS,EAAE,CAAC,CAAC,CAAC;QAC7F,CAAC;QAED,MAAM,GAAG,GAAG,GAAG,CAAC,eAAe,EAAE,IAAI,EAAE,CAAC;QACxC,IAAI,GAAG,EAAE,CAAC;YACR,KAAK,CAAC,IAAI,CAAC,IAAI,QAAQ,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,CAAC,iBAAiB,EAAE,IAAI,EAAE,IAAI,SAAS,EAAE,CAAC,CAAC,CAAC;QACvF,CAAC;QAED,OAAO,IAAI,SAAS,CAAC,KAAK,CAAC,CAAC;IAC9B,CAAC;IAED,IAAI,MAAM;QACR,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IACvC,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,MAAmB,EAAE,IAAa;QAChD,MAAM,OAAO,CAAC,GAAG,CACf,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CACnB,CAAC,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CACrG,CACF,CAAC;IACJ,CAAC;CACF"}
@@ -0,0 +1,32 @@
1
+ import { basename, resolve, sep } from "node:path";
2
+ /**
3
+ * Normalize an operator-configured sink path to an absolute path, rejecting empty or
4
+ * NUL-byte values. A configured sink target is operator-supplied (env), so this is a
5
+ * light sanity guard rather than a sandbox — the real traversal guard is
6
+ * `safeJoinWithin`, which gates every UNTRUSTED leaf name written under a base dir.
7
+ */
8
+ export function resolveConfiguredPath(p) {
9
+ if (!p || p.includes("\0"))
10
+ throw new Error(`invalid sink path: ${JSON.stringify(p)}`);
11
+ return resolve(p);
12
+ }
13
+ /**
14
+ * Join an untrusted leaf name under a trusted base directory, guaranteeing the result
15
+ * cannot escape the base via path traversal. Only the basename is used (any directory
16
+ * components / "../" are stripped) and the resolved path is asserted to stay within
17
+ * `baseDir`. Used when copying screenshots whose source filenames the sink does not own.
18
+ */
19
+ export function safeJoinWithin(baseDir, untrustedLeaf) {
20
+ if (untrustedLeaf.includes("\0"))
21
+ throw new Error("unsafe filename: NUL byte");
22
+ const base = resolve(baseDir);
23
+ const leaf = basename(untrustedLeaf); // collapses "../foo", "/etc/x", "a/b" → a single leaf
24
+ if (!leaf || leaf === "." || leaf === "..")
25
+ throw new Error(`unsafe filename: ${JSON.stringify(untrustedLeaf)}`);
26
+ const full = resolve(base, leaf);
27
+ const prefix = base.endsWith(sep) ? base : base + sep;
28
+ if (full !== base && !full.startsWith(prefix)) {
29
+ throw new Error(`path escapes base dir: ${JSON.stringify(untrustedLeaf)}`);
30
+ }
31
+ return full;
32
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.js","sourceRoot":"","sources":["../../src/sinks/paths.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAEnD;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CAAC,CAAS;IAC7C,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,sBAAsB,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IACvF,OAAO,OAAO,CAAC,CAAC,CAAC,CAAC;AACpB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,OAAe,EAAE,aAAqB;IACnE,IAAI,aAAa,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,CAAC,CAAC;IAC/E,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC9B,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAC,CAAC,CAAC,sDAAsD;IAC5F,IAAI,CAAC,IAAI,IAAI,IAAI,KAAK,GAAG,IAAI,IAAI,KAAK,IAAI;QAAE,MAAM,IAAI,KAAK,CAAC,oBAAoB,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,EAAE,CAAC,CAAC;IACjH,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACjC,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,GAAG,GAAG,CAAC;IACtD,IAAI,IAAI,KAAK,IAAI,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;QAC9C,MAAM,IAAI,KAAK,CAAC,0BAA0B,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,EAAE,CAAC,CAAC;IAC7E,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
@@ -0,0 +1,58 @@
1
+ import { redactResult } from "../core/redact.js";
2
+ /**
3
+ * Build the canonical REDACTED run-summary record.
4
+ *
5
+ * CRITICAL: the record is derived from `redactResult(result)`, so no secret from the
6
+ * checked target — finding urls/messages, the start URL, link-audit URLs, signed-URL
7
+ * params, OAuth fragments — can reach any sink (file line OR HTTP body). Only the
8
+ * caller-supplied `meta` (runId/app/mode/gitSha), which originates locally rather than
9
+ * from the crawled target, is recorded as-is.
10
+ */
11
+ export function buildRunRecord(result, meta) {
12
+ const r = redactResult(result);
13
+ const failCount = r.findings.filter((f) => f.severity === "fail").length;
14
+ const warnCount = r.findings.filter((f) => f.severity === "warn").length;
15
+ const infoCount = r.findings.filter((f) => f.severity === "info").length;
16
+ return {
17
+ runId: meta.runId,
18
+ app: meta.app,
19
+ mode: meta.mode,
20
+ gitSha: meta.gitSha,
21
+ recordedAt: new Date().toISOString(),
22
+ target: r.startUrl,
23
+ durationMs: r.durationMs,
24
+ states: r.visitedStates,
25
+ actions: r.actionsTaken,
26
+ failCount,
27
+ warnCount,
28
+ infoCount,
29
+ tokenIn: r.tokenSpend.input,
30
+ tokenOut: r.tokenSpend.output,
31
+ cappedBy: r.cappedBy,
32
+ coverage: r.coverage
33
+ ? {
34
+ elementCoveragePct: r.coverage.elementCoveragePct,
35
+ statesVisited: r.coverage.statesVisited,
36
+ elementsDiscovered: r.coverage.elementsDiscovered,
37
+ elementsExercised: r.coverage.elementsExercised,
38
+ elementsSkipped: r.coverage.elementsSkipped,
39
+ frameBlindSpots: r.coverage.frameBlindSpots,
40
+ exhaustiveClaimAllowed: r.coverage.exhaustiveClaimAllowed,
41
+ blindSpotSummary: r.coverage.blindSpotSummary,
42
+ cappedNotExhaustive: r.coverage.cappedNotExhaustive,
43
+ extra: r.coverage.extra,
44
+ }
45
+ : undefined,
46
+ baseline: r.baseline ? r.baseline.summary : undefined,
47
+ findings: r.findings.map((f) => ({
48
+ signature: f.signature,
49
+ severity: f.severity,
50
+ kind: f.kind,
51
+ url: f.url,
52
+ message: f.message,
53
+ count: f.count,
54
+ verdictReal: f.verdict?.real,
55
+ verdictReason: f.verdict?.reason,
56
+ })),
57
+ };
58
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"record.js","sourceRoot":"","sources":["../../src/sinks/record.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAoDjD;;;;;;;;GAQG;AACH,MAAM,UAAU,cAAc,CAAC,MAAmB,EAAE,IAAa;IAC/D,MAAM,CAAC,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;IAC/B,MAAM,SAAS,GAAG,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC,CAAC,MAAM,CAAC;IACzE,MAAM,SAAS,GAAG,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC,CAAC,MAAM,CAAC;IACzE,MAAM,SAAS,GAAG,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC,CAAC,MAAM,CAAC;IACzE,OAAO;QACL,KAAK,EAAE,IAAI,CAAC,KAAK;QACjB,GAAG,EAAE,IAAI,CAAC,GAAG;QACb,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,UAAU,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;QACpC,MAAM,EAAE,CAAC,CAAC,QAAQ;QAClB,UAAU,EAAE,CAAC,CAAC,UAAU;QACxB,MAAM,EAAE,CAAC,CAAC,aAAa;QACvB,OAAO,EAAE,CAAC,CAAC,YAAY;QACvB,SAAS;QACT,SAAS;QACT,SAAS;QACT,OAAO,EAAE,CAAC,CAAC,UAAU,CAAC,KAAK;QAC3B,QAAQ,EAAE,CAAC,CAAC,UAAU,CAAC,MAAM;QAC7B,QAAQ,EAAE,CAAC,CAAC,QAAQ;QACpB,QAAQ,EAAE,CAAC,CAAC,QAAQ;YAClB,CAAC,CAAC;gBACE,kBAAkB,EAAE,CAAC,CAAC,QAAQ,CAAC,kBAAkB;gBACjD,aAAa,EAAE,CAAC,CAAC,QAAQ,CAAC,aAAa;gBACvC,kBAAkB,EAAE,CAAC,CAAC,QAAQ,CAAC,kBAAkB;gBACjD,iBAAiB,EAAE,CAAC,CAAC,QAAQ,CAAC,iBAAiB;gBAC/C,eAAe,EAAE,CAAC,CAAC,QAAQ,CAAC,eAAe;gBAC3C,eAAe,EAAE,CAAC,CAAC,QAAQ,CAAC,eAAe;gBAC3C,sBAAsB,EAAE,CAAC,CAAC,QAAQ,CAAC,sBAAsB;gBACzD,gBAAgB,EAAE,CAAC,CAAC,QAAQ,CAAC,gBAAgB;gBAC7C,mBAAmB,EAAE,CAAC,CAAC,QAAQ,CAAC,mBAAmB;gBACnD,KAAK,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK;aACxB;YACH,CAAC,CAAC,SAAS;QACb,QAAQ,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS;QACrD,QAAQ,EAAE,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YAC/B,SAAS,EAAE,CAAC,CAAC,SAAS;YACtB,QAAQ,EAAE,CAAC,CAAC,QAAQ;YACpB,IAAI,EAAE,CAAC,CAAC,IAAI;YACZ,GAAG,EAAE,CAAC,CAAC,GAAG;YACV,OAAO,EAAE,CAAC,CAAC,OAAO;YAClB,KAAK,EAAE,CAAC,CAAC,KAAK;YACd,WAAW,EAAE,CAAC,CAAC,OAAO,EAAE,IAAI;YAC5B,aAAa,EAAE,CAAC,CAAC,OAAO,EAAE,MAAM;SACjC,CAAC,CAAC;KACJ,CAAC;AACJ,CAAC"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/sinks/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,210 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * Console / framework regression canaries. A match turns the crawl into a true
4
+ * regression detector, not just a "did the page render" check. A curated Next.js
5
+ * canary set plus general fatal-error patterns.
6
+ */
7
+ export const CONSOLE_CANARIES = [
8
+ /params should be awaited/i,
9
+ /cookies\(\) should be awaited/i,
10
+ /sync access to (params|cookies|headers)/i,
11
+ /hydration|did not match|text content does not match/i,
12
+ /NEXT_PUBLIC\w*.*(undefined|is not defined)/i,
13
+ /Cannot read propert(y|ies) of (undefined|null)/i,
14
+ /is not a function/i,
15
+ /Unexpected (token|end of)/i,
16
+ ];
17
+ /** Collapse volatile substrings so near-identical errors share a signature. */
18
+ function normalize(message) {
19
+ return message
20
+ .replace(/0x[0-9a-f]+/gi, "0xN")
21
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "UUID")
22
+ .replace(/\b\d+\b/g, "N")
23
+ .replace(/\s+/g, " ")
24
+ .trim()
25
+ .slice(0, 280);
26
+ }
27
+ function pathOf(url) {
28
+ try {
29
+ return new URL(url).pathname;
30
+ }
31
+ catch {
32
+ return url;
33
+ }
34
+ }
35
+ export function signatureOf(kind, message, url) {
36
+ return createHash("sha1")
37
+ .update(`${kind}|${pathOf(url)}|${normalize(message)}`)
38
+ .digest("hex")
39
+ .slice(0, 12);
40
+ }
41
+ function mk(kind, severity, message, url) {
42
+ return { kind, severity, message: message.slice(0, 500), url, signature: signatureOf(kind, message, url) };
43
+ }
44
+ export function isIconImageUrl(src) {
45
+ const fallbackPath = src.split(/[?#]/)[0] ?? "";
46
+ try {
47
+ return new URL(src, "https://comber.local").pathname.toLowerCase().endsWith(".ico");
48
+ }
49
+ catch {
50
+ return fallbackPath.toLowerCase().endsWith(".ico");
51
+ }
52
+ }
53
+ /**
54
+ * Wire deterministic, near-zero-false-positive breakage listeners onto a page.
55
+ * Pushed signals are the raw stream; dedupe + triage happen downstream.
56
+ */
57
+ export function attachBreakageListeners(page, push) {
58
+ page.on("console", (msg) => {
59
+ const text = msg.text();
60
+ if (msg.type() === "error") {
61
+ push(mk("console-error", "warn", text, page.url()));
62
+ }
63
+ for (const canary of CONSOLE_CANARIES) {
64
+ if (canary.test(text)) {
65
+ push(mk("regression-canary", "fail", text, page.url()));
66
+ break;
67
+ }
68
+ }
69
+ });
70
+ page.on("pageerror", (err) => {
71
+ push(mk("uncaught-exception", "fail", `${err.name}: ${err.message}`, page.url()));
72
+ });
73
+ page.on("requestfailed", (req) => {
74
+ const failure = req.failure()?.errorText ?? "request failed";
75
+ // ERR_ABORTED is navigation-caused, not breakage — the crawler aborts in-flight
76
+ // requests every time it navigates between states. Filtering it kills the single
77
+ // biggest false-positive source an exhaustive crawl creates.
78
+ if (/ERR_ABORTED/i.test(failure))
79
+ return;
80
+ if (["document", "script", "stylesheet", "fetch", "xhr"].includes(req.resourceType())) {
81
+ push(mk("request-failed", "warn", `${req.resourceType()} ${req.url()} — ${failure}`, page.url()));
82
+ }
83
+ });
84
+ page.on("response", (res) => {
85
+ const status = res.status();
86
+ const url = res.url();
87
+ if (status >= 500) {
88
+ push(mk("http-5xx", "fail", `${status} ${url}`, page.url()));
89
+ }
90
+ else if (status >= 400 && status !== 401 && status !== 429 && res.request().resourceType() === "document") {
91
+ // 4xx on a top-level navigation = a broken page (404 etc.). 401/429 excluded
92
+ // (auth walls / rate limits are not breakage). Sub-resource 4xx is too noisy.
93
+ push(mk("http-4xx-nav", status === 404 ? "fail" : "warn", `${status} navigation ${url}`, page.url()));
94
+ }
95
+ // Mixed content: an http:// subresource on an https:// page.
96
+ if (/^http:\/\//i.test(url) && /^https:\/\//i.test(page.url())) {
97
+ push(mk("mixed-content", "warn", `insecure resource ${url} on https page`, page.url()));
98
+ }
99
+ });
100
+ }
101
+ /**
102
+ * Init scripts that run before page JS: route unhandledrejection through console.error
103
+ * (so the existing console listener catches it) and stamp a flag we can read in auditDom.
104
+ */
105
+ export async function attachInitScripts(page) {
106
+ await page
107
+ .addInitScript(`
108
+ window.addEventListener("unhandledrejection", (e) => {
109
+ try { console.error("[unhandled-rejection] " + (e.reason && e.reason.message ? e.reason.message : String(e.reason))); } catch (x) {}
110
+ });
111
+ `)
112
+ .catch(() => { });
113
+ }
114
+ /**
115
+ * One string-body DOM sweep for snapshot-time defects: broken images, empty/void links,
116
+ * missing alt, unlabeled fields, duplicate ids, tiny tap targets, i18n missing-keys, and
117
+ * error-boundary screens. Each kind capped to avoid count explosions. Never throws.
118
+ */
119
+ export async function auditDom(page) {
120
+ const js = `(() => {
121
+ const CAP = 20; const out = [];
122
+ const add = (kind, sev, msg) => { if (out.filter((o) => o.kind === kind).length < CAP) out.push({ kind, sev, msg: String(msg).slice(0, 180) }); };
123
+ const vis = (el) => { try { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.display !== "none" && s.visibility !== "hidden"; } catch (e) { return false; } };
124
+ // broken images
125
+ document.querySelectorAll("img").forEach((img) => {
126
+ const src = img.currentSrc || img.src || img.getAttribute("src") || "";
127
+ const cleanSrc = String(src).split(/[?#]/)[0].toLowerCase();
128
+ if (img.complete && img.naturalWidth === 0 && src && !cleanSrc.endsWith(".ico")) add("broken-image", "fail", "broken <img> " + src);
129
+ if (vis(img) && !img.hasAttribute("alt")) add("missing-alt", "warn", "<img> without alt: " + (img.src || "").slice(0, 80));
130
+ });
131
+ // empty / void links
132
+ document.querySelectorAll("a[href]").forEach((a) => {
133
+ const h = a.getAttribute("href");
134
+ if (h === "#" || h === "" || /^javascript:\\s*(void\\(0\\))?;?\\s*$/i.test(h)) { if (vis(a)) add("empty-link", "warn", 'link "' + (a.innerText || "").trim().slice(0, 40) + '" has placeholder href ' + h); }
135
+ });
136
+ // unlabeled form fields
137
+ document.querySelectorAll("input,select,textarea").forEach((el) => {
138
+ const t = (el.type || "").toLowerCase();
139
+ if (["hidden","submit","button","reset","image"].includes(t) || !vis(el)) return;
140
+ const labelled = (el.id && document.querySelector("label[for='" + (window.CSS && CSS.escape ? CSS.escape(el.id) : el.id) + "']")) || el.getAttribute("aria-label") || el.getAttribute("aria-labelledby") || el.getAttribute("placeholder") || el.getAttribute("title");
141
+ if (!labelled) add("unlabeled-field", "warn", "form field without a label: " + (el.name || el.tagName).slice(0, 40));
142
+ });
143
+ // duplicate ids
144
+ const seen = {}; document.querySelectorAll("[id]").forEach((el) => { const id = el.id; if (!id) return; seen[id] = (seen[id] || 0) + 1; });
145
+ Object.keys(seen).forEach((id) => { if (seen[id] > 1) add("duplicate-id", "warn", 'id "' + id + '" used ' + seen[id] + " times"); });
146
+ // tiny tap targets (interactive < 24px)
147
+ document.querySelectorAll("button, [role=button], [role=checkbox], [role=switch], input[type=checkbox], input[type=radio]").forEach((el) => {
148
+ if (!vis(el)) return; const r = el.getBoundingClientRect();
149
+ // icon-like targets small in BOTH dimensions — wide-but-short text links are fine.
150
+ if (r.width > 0 && r.width < 24 && r.height < 24) add("tiny-target", "warn", "tap target " + Math.round(r.width) + "x" + Math.round(r.height) + 'px: "' + (el.innerText || el.getAttribute("aria-label") || "").trim().slice(0, 30) + '"');
151
+ });
152
+ // i18n missing keys + error boundaries in visible text
153
+ const bodyText = (document.body && document.body.innerText) || "";
154
+ const i18n = bodyText.match(/\\[MISSING[_ ]?KEY[^\\]]*\\]|\\{\\{[\\w.]+\\}\\}|missing translation/i);
155
+ if (i18n) add("i18n-missing-key", "fail", "untranslated key visible: " + i18n[0]);
156
+ if (/(something went wrong|application error|client-side exception|this page (could|couldn't) load|an unexpected error)/i.test(bodyText)) add("error-boundary", "fail", "error-boundary copy visible on page");
157
+ return out;
158
+ })()`;
159
+ try {
160
+ const rows = (await page.evaluate(js));
161
+ return rows.map((r) => mk(r.kind, r.sev, r.msg, page.url()));
162
+ }
163
+ catch {
164
+ return [];
165
+ }
166
+ }
167
+ /**
168
+ * After a navigation/action, detect a session bounce (redirect to a login wall / 401 page)
169
+ * so the crawl HALTS instead of crawling 50 login screens. Returns a signal or null.
170
+ */
171
+ export async function sessionLivenessProbe(page) {
172
+ try {
173
+ const url = page.url();
174
+ if (/\/(login|signin|sign-in|auth)(\b|\/|\?)/i.test(url)) {
175
+ // Only when it looks like a bounce: a login form is present and little else.
176
+ const looksLikeWall = (await page.evaluate(`(() => { const hasPw = !!document.querySelector('input[type=password]'); const forms = document.querySelectorAll('form').length; return hasPw && forms <= 2; })()`));
177
+ if (looksLikeWall) {
178
+ return mk("session-expired", "warn", `bounced to a login wall at ${url} — session may be missing/expired`, url);
179
+ }
180
+ }
181
+ }
182
+ catch {
183
+ /* ignore */
184
+ }
185
+ return null;
186
+ }
187
+ /**
188
+ * Run axe-core against the live page for accessibility-critical violations.
189
+ * Judged tier (not auto-fail). Returns signals; never throws.
190
+ */
191
+ export async function runAxe(page) {
192
+ try {
193
+ // axe-core ships a browser bundle; inject it then evaluate.
194
+ const axeSource = (await import("axe-core")).source;
195
+ if (!axeSource)
196
+ return [];
197
+ await page.addScriptTag({ content: axeSource });
198
+ // String body (not a closure) to avoid tsx/esbuild __name injection in the browser.
199
+ const violations = (await page.evaluate(`(async () => {
200
+ const result = await window.axe.run(document, { runOnly: ["wcag2a", "wcag2aa"] });
201
+ return result.violations
202
+ .filter((v) => v.impact === "critical")
203
+ .map((v) => ({ id: v.id, description: v.description }));
204
+ })()`));
205
+ return violations.map((v) => mk("a11y-critical", "warn", `${v.id}: ${v.description}`, page.url()));
206
+ }
207
+ catch {
208
+ return [];
209
+ }
210
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"breakage.js","sourceRoot":"","sources":["../../src/web/breakage.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,102 @@
1
+ import { signatureOf } from "./breakage.js";
2
+ import { restoreFingerprint } from "./fingerprint.js";
3
+ const CONTROL_KINDS = new Set([
4
+ "toggle",
5
+ "tab",
6
+ "accordion",
7
+ "disclosure",
8
+ "dialog-trigger",
9
+ "select",
10
+ "combobox",
11
+ "slider",
12
+ "menuitem",
13
+ ]);
14
+ export function isControl(kind) {
15
+ return CONTROL_KINDS.has(kind);
16
+ }
17
+ function sig(kind, severity, message, el, url) {
18
+ return { kind, severity, message: message.slice(0, 220), url, signature: signatureOf(kind, el.domPath, url) };
19
+ }
20
+ /** Re-read an element's current state by stable domPath after an interaction. */
21
+ async function reread(driver, el) {
22
+ const s = await driver.webSnapshot().catch(() => null);
23
+ if (!s)
24
+ return undefined;
25
+ return s.interactive.find((e) => e.domPath === el.domPath && (e.framePath?.[0] ?? "") === (el.framePath?.[0] ?? ""));
26
+ }
27
+ /**
28
+ * Exercise a stateful control with the assert-changed contract: act → assert a concrete
29
+ * aria/value fact changed → (self-restore where cheap). A control whose click changes
30
+ * nothing observable is a dead-* finding. Modals are opened, counted, and Escape-closed
31
+ * (a dialog that won't close is dialog-stuck = fail / focus-trap).
32
+ */
33
+ export async function exerciseControl(driver, el) {
34
+ const url = driver.currentUrl();
35
+ switch (el.kind) {
36
+ case "toggle": {
37
+ const before = el.checkedState ?? (el.ariaPressed === undefined ? undefined : String(el.ariaPressed));
38
+ await driver.act(el);
39
+ const after = await reread(driver, el);
40
+ const now = after?.checkedState ?? (after?.ariaPressed === undefined ? undefined : String(after.ariaPressed));
41
+ const changed = after !== undefined && now !== before;
42
+ if (changed)
43
+ await driver.act(el); // self-restore (toggle back)
44
+ return { changed, modalOpened: false, signals: changed ? [] : [sig("dead-toggle", "warn", `toggle "${el.name}" click did not flip checked state`, el, url)] };
45
+ }
46
+ case "tab": {
47
+ await driver.act(el);
48
+ const after = await reread(driver, el);
49
+ const changed = after?.ariaSelected === "true" || (after !== undefined && after.ariaSelected !== el.ariaSelected);
50
+ return { changed, modalOpened: false, signals: changed ? [] : [sig("dead-tab", "warn", `tab "${el.name}" did not become selected`, el, url)] };
51
+ }
52
+ case "accordion":
53
+ case "disclosure": {
54
+ const before = el.ariaExpanded;
55
+ await driver.act(el);
56
+ const after = await reread(driver, el);
57
+ const changed = after !== undefined && after.ariaExpanded !== before;
58
+ if (changed && after?.ariaExpanded)
59
+ await driver.act(el); // collapse back
60
+ return { changed, modalOpened: false, signals: changed ? [] : [sig("dead-disclosure", "warn", `disclosure "${el.name}" aria-expanded did not change`, el, url)] };
61
+ }
62
+ case "dialog-trigger": {
63
+ const fp0 = restoreFingerprint(await driver.webSnapshot());
64
+ await driver.act(el);
65
+ const fp1 = restoreFingerprint(await driver.webSnapshot());
66
+ if (fp1 === fp0) {
67
+ return { changed: false, modalOpened: false, signals: [sig("dead-dialog-trigger", "warn", `dialog trigger "${el.name}" opened nothing`, el, url)] };
68
+ }
69
+ await driver.pressKey("Escape");
70
+ const fp2 = restoreFingerprint(await driver.webSnapshot());
71
+ const closed = fp2 === fp0;
72
+ return {
73
+ changed: true,
74
+ modalOpened: true,
75
+ signals: closed ? [] : [sig("dialog-stuck", "fail", `dialog from "${el.name}" did not close on Escape (focus-trap?)`, el, url)],
76
+ };
77
+ }
78
+ case "select":
79
+ case "combobox": {
80
+ if (el.kind === "select" && (el.optionCount ?? 0) === 0) {
81
+ return { changed: false, modalOpened: false, signals: [sig("empty-listbox", "warn", `select "${el.name}" has no options`, el, url)] };
82
+ }
83
+ const before = el.valueNow;
84
+ await driver.selectOption(el);
85
+ const after = await reread(driver, el);
86
+ const changed = after !== undefined && after.valueNow !== before;
87
+ return { changed, modalOpened: false, signals: changed ? [] : [sig("dead-select", "warn", `select/combobox "${el.name}" value did not change`, el, url)] };
88
+ }
89
+ case "slider": {
90
+ const before = el.valueNow;
91
+ await driver.nudge(el);
92
+ const after = await reread(driver, el);
93
+ const changed = after !== undefined && after.valueNow !== before;
94
+ return { changed, modalOpened: false, signals: changed ? [] : [sig("dead-slider", "warn", `slider "${el.name}" value did not change on ArrowRight`, el, url)] };
95
+ }
96
+ case "menuitem":
97
+ default: {
98
+ await driver.act(el);
99
+ return { changed: true, modalOpened: false, signals: [] };
100
+ }
101
+ }
102
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"controls.js","sourceRoot":"","sources":["../../src/web/controls.ts"],"names":[],"mappings":"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"}