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.
- package/LICENSE +18 -0
- package/README.md +732 -0
- package/action.yml +279 -0
- package/comber.config.example.json +64 -0
- package/dist/api/classify.js +99 -0
- package/dist/api/classify.js.map +1 -0
- package/dist/api/driver.js +235 -0
- package/dist/api/driver.js.map +1 -0
- package/dist/api/graphql-driver.js +150 -0
- package/dist/api/graphql-driver.js.map +1 -0
- package/dist/api/graphql.js +115 -0
- package/dist/api/graphql.js.map +1 -0
- package/dist/api/har-driver.js +167 -0
- package/dist/api/har-driver.js.map +1 -0
- package/dist/api/har.js +100 -0
- package/dist/api/har.js.map +1 -0
- package/dist/api/load.js +83 -0
- package/dist/api/load.js.map +1 -0
- package/dist/api/openapi.js +159 -0
- package/dist/api/openapi.js.map +1 -0
- package/dist/api/request.js +141 -0
- package/dist/api/request.js.map +1 -0
- package/dist/api/schema.js +126 -0
- package/dist/api/schema.js.map +1 -0
- package/dist/cli.js +352 -0
- package/dist/cli.js.map +1 -0
- package/dist/comment.js +164 -0
- package/dist/comment.js.map +1 -0
- package/dist/config.js +664 -0
- package/dist/config.js.map +1 -0
- package/dist/connectors/builtin/discord.js +55 -0
- package/dist/connectors/builtin/github-checks.js +53 -0
- package/dist/connectors/builtin/junit.js +70 -0
- package/dist/connectors/builtin/linear.js +58 -0
- package/dist/connectors/builtin/slack.js +60 -0
- package/dist/connectors/builtin/webhook.js +39 -0
- package/dist/connectors/index.js +3 -0
- package/dist/connectors/loader.js +72 -0
- package/dist/connectors/registry.js +28 -0
- package/dist/connectors/types.js +23 -0
- package/dist/core/action-gate.js +48 -0
- package/dist/core/baseline.js +85 -0
- package/dist/core/baseline.js.map +1 -0
- package/dist/core/blind-spots.js +22 -0
- package/dist/core/blind-spots.js.map +1 -0
- package/dist/core/contract-extras.js +1 -0
- package/dist/core/contract-extras.js.map +1 -0
- package/dist/core/contract.js +1 -0
- package/dist/core/contract.js.map +1 -0
- package/dist/core/coverage.js +70 -0
- package/dist/core/coverage.js.map +1 -0
- package/dist/core/crawl.js +229 -0
- package/dist/core/crawl.js.map +1 -0
- package/dist/core/egress.js +128 -0
- package/dist/core/egress.js.map +1 -0
- package/dist/core/ledger.js +41 -0
- package/dist/core/ledger.js.map +1 -0
- package/dist/core/net.js +39 -0
- package/dist/core/pipeline.js +191 -0
- package/dist/core/pipeline.js.map +1 -0
- package/dist/core/plan.js +121 -0
- package/dist/core/plan.js.map +1 -0
- package/dist/core/redact.js +240 -0
- package/dist/core/redact.js.map +1 -0
- package/dist/core/replay.js +263 -0
- package/dist/core/replay.js.map +1 -0
- package/dist/core/report.js +253 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/safety-judge.js +116 -0
- package/dist/core/safety.js +92 -0
- package/dist/core/safety.js.map +1 -0
- package/dist/core/signal-normalize.js +190 -0
- package/dist/core/signal-normalize.js.map +1 -0
- package/dist/core/signals.js +5 -0
- package/dist/core/signals.js.map +1 -0
- package/dist/core/triage.js +104 -0
- package/dist/core/triage.js.map +1 -0
- package/dist/core/types.js +1 -0
- package/dist/core/types.js.map +1 -0
- package/dist/desktop/desktop-profile.js +6 -0
- package/dist/desktop/desktop-profile.js.map +1 -0
- package/dist/desktop/driver.js +433 -0
- package/dist/desktop/driver.js.map +1 -0
- package/dist/desktop/evidence.js +206 -0
- package/dist/desktop/evidence.js.map +1 -0
- package/dist/desktop/macos/accessibility-runner.js +118 -0
- package/dist/desktop/macos/accessibility-runner.js.map +1 -0
- package/dist/desktop/macos/types.js +1 -0
- package/dist/desktop/macos/types.js.map +1 -0
- package/dist/desktop/preflight.js +107 -0
- package/dist/desktop/preflight.js.map +1 -0
- package/dist/desktop/proxy-proof.js +185 -0
- package/dist/desktop/proxy-proof.js.map +1 -0
- package/dist/desktop/view.js +348 -0
- package/dist/desktop/view.js.map +1 -0
- package/dist/desktop/windows/types.js +1 -0
- package/dist/desktop/windows/types.js.map +1 -0
- package/dist/desktop/windows/uia-runner.js +127 -0
- package/dist/desktop/windows/uia-runner.js.map +1 -0
- package/dist/native/apple.js +14 -0
- package/dist/native/apple.js.map +1 -0
- package/dist/native/device-profile.js +6 -0
- package/dist/native/device-profile.js.map +1 -0
- package/dist/native/driver.js +416 -0
- package/dist/native/driver.js.map +1 -0
- package/dist/native/evidence.js +164 -0
- package/dist/native/evidence.js.map +1 -0
- package/dist/native/load.js +9 -0
- package/dist/native/load.js.map +1 -0
- package/dist/native/maestro.js +153 -0
- package/dist/native/maestro.js.map +1 -0
- package/dist/native/preflight.js +72 -0
- package/dist/native/preflight.js.map +1 -0
- package/dist/native/proxy-proof.js +182 -0
- package/dist/native/proxy-proof.js.map +1 -0
- package/dist/native/samsung.js +34 -0
- package/dist/native/samsung.js.map +1 -0
- package/dist/native/signals.js +50 -0
- package/dist/native/signals.js.map +1 -0
- package/dist/native/view.js +139 -0
- package/dist/native/view.js.map +1 -0
- package/dist/profile.js +237 -0
- package/dist/profile.js.map +1 -0
- package/dist/sink.js +134 -0
- package/dist/sink.js.map +1 -0
- package/dist/sinks/file.js +49 -0
- package/dist/sinks/file.js.map +1 -0
- package/dist/sinks/http.js +38 -0
- package/dist/sinks/http.js.map +1 -0
- package/dist/sinks/index.js +5 -0
- package/dist/sinks/index.js.map +1 -0
- package/dist/sinks/multi.js +40 -0
- package/dist/sinks/multi.js.map +1 -0
- package/dist/sinks/paths.js +32 -0
- package/dist/sinks/paths.js.map +1 -0
- package/dist/sinks/record.js +58 -0
- package/dist/sinks/record.js.map +1 -0
- package/dist/sinks/types.js +1 -0
- package/dist/sinks/types.js.map +1 -0
- package/dist/web/breakage.js +210 -0
- package/dist/web/breakage.js.map +1 -0
- package/dist/web/controls.js +102 -0
- package/dist/web/controls.js.map +1 -0
- package/dist/web/driver.js +803 -0
- package/dist/web/driver.js.map +1 -0
- package/dist/web/elements.js +151 -0
- package/dist/web/elements.js.map +1 -0
- package/dist/web/fingerprint.js +13 -0
- package/dist/web/fingerprint.js.map +1 -0
- package/dist/web/forms.js +74 -0
- package/dist/web/forms.js.map +1 -0
- package/dist/web/links.js +194 -0
- package/dist/web/links.js.map +1 -0
- package/dist/web/types.js +1 -0
- package/dist/web/types.js.map +1 -0
- package/package.json +80 -0
- package/runtime/macos-accessibility-runner.js +275 -0
- 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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"}
|