jourilo 0.1.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -35
- package/package.json +1 -1
- package/plugin/.claude-plugin/icon.png +0 -0
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/dist/jourilo.mjs +2359 -598
- package/plugin/skills/jourilo/SKILL.md +2 -2
- package/plugin/skills/jourilo/references/cli.md +38 -12
- package/plugin/skills/optimize/SKILL.md +5 -2
package/plugin/dist/jourilo.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Jourilo 0.1
|
|
2
|
+
// Jourilo 1.0.1 - generated by scripts/build.mjs. Do not edit; edit src/ instead.
|
|
3
3
|
import { createRequire as __jouriloCreateRequire } from 'node:module';
|
|
4
4
|
const require = __jouriloCreateRequire(import.meta.url);
|
|
5
5
|
var __create = Object.create;
|
|
@@ -82,16 +82,26 @@ var init_errors = __esm({
|
|
|
82
82
|
// src/core/fs-safety.ts
|
|
83
83
|
import { chmodSync, existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
84
84
|
import { homedir } from "node:os";
|
|
85
|
-
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
85
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
86
86
|
function resolveInside(root, child) {
|
|
87
87
|
if (child.includes("\0")) throw new JouriloError("UNSAFE_PATH", "Path contains a NUL byte.");
|
|
88
|
-
const base2 =
|
|
89
|
-
const
|
|
90
|
-
const real = existsSync(target) ? realpathSync(target) : target;
|
|
88
|
+
const base2 = realpathDeep(resolve(root));
|
|
89
|
+
const real = realpathDeep(resolve(base2, child));
|
|
91
90
|
const rel = relative(base2, real);
|
|
92
91
|
if (rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)) return real;
|
|
93
92
|
throw new JouriloError("UNSAFE_PATH", `Path escapes ${root}: ${child}`);
|
|
94
93
|
}
|
|
94
|
+
function realpathDeep(path2) {
|
|
95
|
+
let existing = path2;
|
|
96
|
+
const rest = [];
|
|
97
|
+
while (!existsSync(existing)) {
|
|
98
|
+
const parent = dirname(existing);
|
|
99
|
+
if (parent === existing) return path2;
|
|
100
|
+
rest.unshift(basename(existing));
|
|
101
|
+
existing = parent;
|
|
102
|
+
}
|
|
103
|
+
return join(realpathSync(existing), ...rest);
|
|
104
|
+
}
|
|
95
105
|
function writeFileSafe(path2, data, opts = {}) {
|
|
96
106
|
mkdirSync(dirname(path2), { recursive: true });
|
|
97
107
|
writeFileSync(path2, data, opts.private ? { mode: 384 } : void 0);
|
|
@@ -129,7 +139,7 @@ __export(workspace_exports, {
|
|
|
129
139
|
Workspace: () => Workspace
|
|
130
140
|
});
|
|
131
141
|
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
132
|
-
import { dirname as dirname2, join, resolve as resolve2 } from "node:path";
|
|
142
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
|
|
133
143
|
var Workspace;
|
|
134
144
|
var init_workspace = __esm({
|
|
135
145
|
"src/core/workspace.ts"() {
|
|
@@ -143,11 +153,11 @@ var init_workspace = __esm({
|
|
|
143
153
|
this.root = resolve2(root ?? process.env.JOURILO_WORKSPACE ?? ".jourilo");
|
|
144
154
|
}
|
|
145
155
|
get runsDir() {
|
|
146
|
-
return
|
|
156
|
+
return join2(this.root, "runs");
|
|
147
157
|
}
|
|
148
158
|
ensure() {
|
|
149
159
|
mkdirSync2(this.runsDir, { recursive: true });
|
|
150
|
-
const ignore =
|
|
160
|
+
const ignore = join2(this.root, ".gitignore");
|
|
151
161
|
if (!existsSync2(ignore)) writeFileSync2(ignore, "*\n");
|
|
152
162
|
}
|
|
153
163
|
runDir(id) {
|
|
@@ -156,12 +166,12 @@ var init_workspace = __esm({
|
|
|
156
166
|
createRunDir(id) {
|
|
157
167
|
this.ensure();
|
|
158
168
|
const dir = this.runDir(id);
|
|
159
|
-
mkdirSync2(
|
|
169
|
+
mkdirSync2(join2(dir, "screenshots"), { recursive: true });
|
|
160
170
|
return dir;
|
|
161
171
|
}
|
|
162
172
|
saveRun(run) {
|
|
163
173
|
const dir = this.createRunDir(run.id);
|
|
164
|
-
const file =
|
|
174
|
+
const file = join2(dir, "run.json");
|
|
165
175
|
writeFileSync2(file, `${JSON.stringify(run, null, 2)}
|
|
166
176
|
`);
|
|
167
177
|
return file;
|
|
@@ -170,7 +180,7 @@ var init_workspace = __esm({
|
|
|
170
180
|
if (!existsSync2(this.runsDir)) return [];
|
|
171
181
|
const out = [];
|
|
172
182
|
for (const id of readdirSync(this.runsDir).sort()) {
|
|
173
|
-
const file =
|
|
183
|
+
const file = join2(this.runsDir, id, "run.json");
|
|
174
184
|
if (!existsSync2(file)) continue;
|
|
175
185
|
try {
|
|
176
186
|
const run = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -196,13 +206,13 @@ var init_workspace = __esm({
|
|
|
196
206
|
const runs2 = this.listRuns();
|
|
197
207
|
const pick = runs2[runs2.length - (ref === "latest" ? 1 : 2)];
|
|
198
208
|
if (!pick) throw new JouriloError("NOT_FOUND", `No ${ref} run in ${this.runsDir}.`);
|
|
199
|
-
file =
|
|
209
|
+
file = join2(this.runsDir, pick.id, "run.json");
|
|
200
210
|
} else if (ref.endsWith(".json") && existsSync2(ref)) {
|
|
201
211
|
file = resolve2(ref);
|
|
202
|
-
} else if (existsSync2(
|
|
212
|
+
} else if (existsSync2(join2(ref, "run.json"))) {
|
|
203
213
|
file = resolve2(ref, "run.json");
|
|
204
214
|
} else {
|
|
205
|
-
file =
|
|
215
|
+
file = join2(this.runDir(ref), "run.json");
|
|
206
216
|
}
|
|
207
217
|
if (!existsSync2(file)) throw new JouriloError("NOT_FOUND", `Run not found: ${ref}`);
|
|
208
218
|
const run = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -38811,7 +38821,7 @@ var require_axe = __commonJS({
|
|
|
38811
38821
|
init_errors();
|
|
38812
38822
|
|
|
38813
38823
|
// src/core/version.ts
|
|
38814
|
-
var VERSION = true ? "0.1
|
|
38824
|
+
var VERSION = true ? "1.0.1" : "0.0.0-dev";
|
|
38815
38825
|
|
|
38816
38826
|
// src/cli/framework.ts
|
|
38817
38827
|
init_errors();
|
|
@@ -38884,7 +38894,7 @@ import { fileURLToPath } from "node:url";
|
|
|
38884
38894
|
|
|
38885
38895
|
// src/agents/adapters.ts
|
|
38886
38896
|
import { homedir as homedir2 } from "node:os";
|
|
38887
|
-
import { join as
|
|
38897
|
+
import { join as join3 } from "node:path";
|
|
38888
38898
|
var MARK_START = "<!-- jourilo:start -->";
|
|
38889
38899
|
var MARK_END = "<!-- jourilo:end -->";
|
|
38890
38900
|
function instructions(cmd) {
|
|
@@ -38922,7 +38932,7 @@ var ADAPTERS = [
|
|
|
38922
38932
|
id: "claude-skill",
|
|
38923
38933
|
name: "Claude Code personal skill (adds a bare /jourilo command)",
|
|
38924
38934
|
scope: "user",
|
|
38925
|
-
path: () =>
|
|
38935
|
+
path: () => join3(homedir2(), ".claude", "skills", "jourilo", "SKILL.md"),
|
|
38926
38936
|
render: (cmd) => `---
|
|
38927
38937
|
name: jourilo
|
|
38928
38938
|
description: Jourilo product experience & conversion engineering \u2014 audit UX, accessibility, performance and conversion in a real browser, run user journeys, fix root causes and verify. Use for /jourilo.
|
|
@@ -38942,14 +38952,14 @@ ${instructions(cmd)}`
|
|
|
38942
38952
|
name: "OpenAI Codex / AGENTS.md-compatible agents",
|
|
38943
38953
|
scope: "project",
|
|
38944
38954
|
section: true,
|
|
38945
|
-
path: (root) =>
|
|
38955
|
+
path: (root) => join3(root, "AGENTS.md"),
|
|
38946
38956
|
render: (cmd) => instructions(cmd)
|
|
38947
38957
|
},
|
|
38948
38958
|
{
|
|
38949
38959
|
id: "cursor",
|
|
38950
38960
|
name: "Cursor project rule",
|
|
38951
38961
|
scope: "project",
|
|
38952
|
-
path: (root) =>
|
|
38962
|
+
path: (root) => join3(root, ".cursor", "rules", "jourilo.mdc"),
|
|
38953
38963
|
render: (cmd) => `---
|
|
38954
38964
|
description: Jourilo product experience & conversion engineering (UX, accessibility, journeys, verified fixes)
|
|
38955
38965
|
alwaysApply: false
|
|
@@ -39028,7 +39038,7 @@ import { resolve as resolve5 } from "node:path";
|
|
|
39028
39038
|
// src/browser/capture.ts
|
|
39029
39039
|
var import_axe_core = __toESM(require_axe(), 1);
|
|
39030
39040
|
init_fs_safety();
|
|
39031
|
-
import { join as
|
|
39041
|
+
import { join as join5 } from "node:path";
|
|
39032
39042
|
|
|
39033
39043
|
// src/core/hash.ts
|
|
39034
39044
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -39506,6 +39516,246 @@ function excerpt(input, max = 240) {
|
|
|
39506
39516
|
return clean.length > max ? `${clean.slice(0, max - 1)}\u2026` : clean;
|
|
39507
39517
|
}
|
|
39508
39518
|
|
|
39519
|
+
// src/journeys/safety.ts
|
|
39520
|
+
var DESTRUCTIVE = lexicon("destructive");
|
|
39521
|
+
var FINANCIAL = lexicon("financial");
|
|
39522
|
+
function signalWords(raw) {
|
|
39523
|
+
return raw.replace(new RegExp("([\\p{Ll}\\p{N}])(\\p{Lu})", "gu"), "$1 $2").replace(/[^\p{L}\p{N}]+/gu, " ").trim().toLowerCase();
|
|
39524
|
+
}
|
|
39525
|
+
var DESTRUCTIVE_WORDS = new RegExp(
|
|
39526
|
+
`(?<![\\p{L}\\p{N}])(${[
|
|
39527
|
+
"delete",
|
|
39528
|
+
"destroy",
|
|
39529
|
+
"remove",
|
|
39530
|
+
"erase",
|
|
39531
|
+
"purge",
|
|
39532
|
+
"wipe",
|
|
39533
|
+
"trash",
|
|
39534
|
+
"cancel",
|
|
39535
|
+
"unsubscribe",
|
|
39536
|
+
"deactivate",
|
|
39537
|
+
"terminate",
|
|
39538
|
+
"revoke",
|
|
39539
|
+
"close account",
|
|
39540
|
+
"sil",
|
|
39541
|
+
"silme",
|
|
39542
|
+
"kaldir",
|
|
39543
|
+
"kald\u0131r",
|
|
39544
|
+
"iptal",
|
|
39545
|
+
"loeschen",
|
|
39546
|
+
"l\xF6schen",
|
|
39547
|
+
"entfernen",
|
|
39548
|
+
"kuendigen",
|
|
39549
|
+
"k\xFCndigen",
|
|
39550
|
+
"eliminar",
|
|
39551
|
+
"borrar",
|
|
39552
|
+
"cancelar",
|
|
39553
|
+
"dar de baja",
|
|
39554
|
+
"supprimer",
|
|
39555
|
+
"effacer",
|
|
39556
|
+
"resilier",
|
|
39557
|
+
"r\xE9silier",
|
|
39558
|
+
"annuler",
|
|
39559
|
+
"desabonner",
|
|
39560
|
+
"d\xE9sabonner"
|
|
39561
|
+
].join("|")})(?![\\p{L}\\p{N}])`,
|
|
39562
|
+
"iu"
|
|
39563
|
+
);
|
|
39564
|
+
var FINANCIAL_WORDS = new RegExp(
|
|
39565
|
+
`(?<![\\p{L}\\p{N}])(${[
|
|
39566
|
+
"pay",
|
|
39567
|
+
"payment",
|
|
39568
|
+
"payments",
|
|
39569
|
+
"checkout",
|
|
39570
|
+
"purchase",
|
|
39571
|
+
"charge",
|
|
39572
|
+
"charges",
|
|
39573
|
+
"order",
|
|
39574
|
+
"orders",
|
|
39575
|
+
"billing",
|
|
39576
|
+
"subscription",
|
|
39577
|
+
"transfer",
|
|
39578
|
+
"withdraw",
|
|
39579
|
+
"withdrawal",
|
|
39580
|
+
"donate",
|
|
39581
|
+
"payout",
|
|
39582
|
+
"odeme",
|
|
39583
|
+
"\xF6deme",
|
|
39584
|
+
"satin al",
|
|
39585
|
+
"sat\u0131n al",
|
|
39586
|
+
"siparis",
|
|
39587
|
+
"sipari\u015F",
|
|
39588
|
+
"zahlung",
|
|
39589
|
+
"bezahlen",
|
|
39590
|
+
"kaufen",
|
|
39591
|
+
"bestellung",
|
|
39592
|
+
"pago",
|
|
39593
|
+
"pagar",
|
|
39594
|
+
"comprar",
|
|
39595
|
+
"pedido",
|
|
39596
|
+
"paiement",
|
|
39597
|
+
"payer",
|
|
39598
|
+
"acheter",
|
|
39599
|
+
"commande"
|
|
39600
|
+
].join("|")})(?![\\p{L}\\p{N}])`,
|
|
39601
|
+
"iu"
|
|
39602
|
+
);
|
|
39603
|
+
var DIALOG_WORDS = /^(cancel|cancelar|annuler|iptal)$/i;
|
|
39604
|
+
function classifyAction(name, signals = "", targets = "") {
|
|
39605
|
+
const attrWords = signalWords(signals).split(" ").filter((w) => !DIALOG_WORDS.test(w)).join(" ");
|
|
39606
|
+
const urlWords = signalWords(targets);
|
|
39607
|
+
if (DESTRUCTIVE.test(name) || attrWords && DESTRUCTIVE_WORDS.test(attrWords) || urlWords && DESTRUCTIVE_WORDS.test(urlWords))
|
|
39608
|
+
return "destructive";
|
|
39609
|
+
if (FINANCIAL.test(name)) return "financial";
|
|
39610
|
+
return null;
|
|
39611
|
+
}
|
|
39612
|
+
function checkAction(ctx, policy) {
|
|
39613
|
+
const safeHost = policy.hostClass === "local";
|
|
39614
|
+
const label = ctx.name || signalWords(`${ctx.signals ?? ""} ${ctx.targets ?? ""}`).slice(0, 60) || "this control";
|
|
39615
|
+
const risky = classifyAction(ctx.name, ctx.signals, ctx.targets) ?? (ctx.paymentForm ? "financial" : null);
|
|
39616
|
+
if (risky) {
|
|
39617
|
+
if (!safeHost)
|
|
39618
|
+
return {
|
|
39619
|
+
allowed: false,
|
|
39620
|
+
reason: `"${label}" looks ${risky === "financial" ? "like a payment/purchase" : "irreversible"}; Jourilo never performs it on a non-local host.`
|
|
39621
|
+
};
|
|
39622
|
+
if (!policy.allowDestructive)
|
|
39623
|
+
return {
|
|
39624
|
+
allowed: false,
|
|
39625
|
+
reason: `"${label}" looks ${risky === "financial" ? "like a payment/purchase" : "irreversible"}; rerun with --allow-destructive on a local/test environment with sandbox data to include it.`
|
|
39626
|
+
};
|
|
39627
|
+
}
|
|
39628
|
+
if (ctx.submitsForm && !safeHost && !policy.allowSubmit) {
|
|
39629
|
+
return {
|
|
39630
|
+
allowed: false,
|
|
39631
|
+
reason: "Submitting a form on a non-local host is blocked; use --allow-submit only on an environment you are authorized to test, with test accounts."
|
|
39632
|
+
};
|
|
39633
|
+
}
|
|
39634
|
+
return { allowed: true };
|
|
39635
|
+
}
|
|
39636
|
+
var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
39637
|
+
function isGraphqlRead(body) {
|
|
39638
|
+
if (!body) return false;
|
|
39639
|
+
try {
|
|
39640
|
+
const q = JSON.parse(body).query;
|
|
39641
|
+
return typeof q === "string" && /^\s*(query\b|\{)/.test(q) && !/\bmutation\b/.test(q);
|
|
39642
|
+
} catch {
|
|
39643
|
+
return false;
|
|
39644
|
+
}
|
|
39645
|
+
}
|
|
39646
|
+
function checkRequest(r, policy) {
|
|
39647
|
+
let u;
|
|
39648
|
+
try {
|
|
39649
|
+
u = new URL(r.url);
|
|
39650
|
+
} catch {
|
|
39651
|
+
return { allowed: true };
|
|
39652
|
+
}
|
|
39653
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return { allowed: true };
|
|
39654
|
+
const method = r.method.toUpperCase();
|
|
39655
|
+
const firstParty = u.origin === policy.origin;
|
|
39656
|
+
const safeHost = policy.hostClass === "local";
|
|
39657
|
+
if (r.isNavigation && r.mainFrame && !firstParty && !(policy.allowOrigins ?? []).includes(u.origin))
|
|
39658
|
+
return {
|
|
39659
|
+
allowed: false,
|
|
39660
|
+
kind: "off-origin",
|
|
39661
|
+
reason: `Navigation to ${u.origin} leaves the target origin ${policy.origin}; Jourilo does not act on other sites (allow it with --allow-origin ${u.origin}).`
|
|
39662
|
+
};
|
|
39663
|
+
const words = signalWords(`${u.pathname} ${u.search}`);
|
|
39664
|
+
const mutating = !SAFE_METHODS.has(method);
|
|
39665
|
+
const risky = method === "DELETE" || r.isNavigation && !mutating && DESTRUCTIVE_WORDS.test(words) ? "destructive" : mutating && DESTRUCTIVE_WORDS.test(words) ? "destructive" : mutating && FINANCIAL_WORDS.test(words) ? "financial" : null;
|
|
39666
|
+
const what = `${method} ${u.pathname}`;
|
|
39667
|
+
if (risky && (policy.mode === "read-only" || !safeHost || !policy.allowDestructive))
|
|
39668
|
+
return {
|
|
39669
|
+
allowed: false,
|
|
39670
|
+
kind: risky,
|
|
39671
|
+
reason: `${what} looks ${risky === "financial" ? "like a payment/purchase" : "irreversible"}; ${policy.mode === "read-only" ? "audits never send it" : safeHost ? "rerun with --allow-destructive on a local/test environment with sandbox data to include it" : "Jourilo never sends it to a non-local host"}.`
|
|
39672
|
+
};
|
|
39673
|
+
if (!mutating || isGraphqlRead(r.body)) return { allowed: true };
|
|
39674
|
+
if (policy.mode === "read-only")
|
|
39675
|
+
return {
|
|
39676
|
+
allowed: false,
|
|
39677
|
+
kind: "submit",
|
|
39678
|
+
reason: `${what} would send data; audits only observe (form probes simulate the submission instead).`
|
|
39679
|
+
};
|
|
39680
|
+
if (!safeHost && !policy.allowSubmit && (firstParty || r.isNavigation))
|
|
39681
|
+
return {
|
|
39682
|
+
allowed: false,
|
|
39683
|
+
kind: "submit",
|
|
39684
|
+
reason: `${what} sends data to a non-local host; use --allow-submit only on an environment you are authorized to test, with test accounts.`
|
|
39685
|
+
};
|
|
39686
|
+
return { allowed: true };
|
|
39687
|
+
}
|
|
39688
|
+
function testData(runId, vars = {}) {
|
|
39689
|
+
const tag = runId.replace(/[^a-z0-9]/gi, "").slice(-10).toLowerCase();
|
|
39690
|
+
const base2 = {
|
|
39691
|
+
email: `jourilo+${tag}@example.test`,
|
|
39692
|
+
password: `Jr-${tag}-Q7!x`,
|
|
39693
|
+
name: "Jordan Test",
|
|
39694
|
+
firstName: "Jordan",
|
|
39695
|
+
lastName: "Test",
|
|
39696
|
+
phone: "+1 555 0100",
|
|
39697
|
+
company: "Example Test Co",
|
|
39698
|
+
search: "test",
|
|
39699
|
+
zip: "10001",
|
|
39700
|
+
// Published test card from Stripe's docs; only useful in a provider's test mode.
|
|
39701
|
+
testCard: "4242 4242 4242 4242"
|
|
39702
|
+
};
|
|
39703
|
+
return { ...base2, ...vars };
|
|
39704
|
+
}
|
|
39705
|
+
function interpolate(value, data) {
|
|
39706
|
+
return value.replace(/\{\{\s*([A-Za-z][\w]*)\s*\}\}/g, (m, k) => data[k] ?? m);
|
|
39707
|
+
}
|
|
39708
|
+
function isSecretValue(raw) {
|
|
39709
|
+
return /\{\{\s*(password|testCard|secret|token)\s*\}\}/i.test(raw);
|
|
39710
|
+
}
|
|
39711
|
+
|
|
39712
|
+
// src/journeys/request-guard.ts
|
|
39713
|
+
async function installRequestGuard(target, policy) {
|
|
39714
|
+
const blocked = [];
|
|
39715
|
+
const urls = /* @__PURE__ */ new Set();
|
|
39716
|
+
const handler = async (route) => {
|
|
39717
|
+
const req = route.request();
|
|
39718
|
+
let mainFrame = req.isNavigationRequest();
|
|
39719
|
+
try {
|
|
39720
|
+
const frame = req.frame();
|
|
39721
|
+
mainFrame = frame.parentFrame() === null;
|
|
39722
|
+
} catch {
|
|
39723
|
+
}
|
|
39724
|
+
const verdict = checkRequest(
|
|
39725
|
+
{
|
|
39726
|
+
url: req.url(),
|
|
39727
|
+
method: req.method(),
|
|
39728
|
+
isNavigation: req.isNavigationRequest(),
|
|
39729
|
+
mainFrame,
|
|
39730
|
+
body: req.postData()
|
|
39731
|
+
},
|
|
39732
|
+
policy
|
|
39733
|
+
);
|
|
39734
|
+
if (!verdict.allowed) {
|
|
39735
|
+
if (blocked.length < 200)
|
|
39736
|
+
blocked.push({
|
|
39737
|
+
url: redactUrl(req.url()),
|
|
39738
|
+
method: req.method(),
|
|
39739
|
+
reason: verdict.reason,
|
|
39740
|
+
kind: verdict.kind ?? "submit",
|
|
39741
|
+
navigation: req.isNavigationRequest(),
|
|
39742
|
+
at: Date.now()
|
|
39743
|
+
});
|
|
39744
|
+
urls.add(req.url());
|
|
39745
|
+
await route.abort("blockedbyclient").catch(() => void 0);
|
|
39746
|
+
return;
|
|
39747
|
+
}
|
|
39748
|
+
await route.fallback().catch(() => void 0);
|
|
39749
|
+
};
|
|
39750
|
+
await target.route("**/*", handler);
|
|
39751
|
+
return {
|
|
39752
|
+
blocked,
|
|
39753
|
+
isBlocked: (u) => urls.has(u) || urls.has(u.split("#")[0]),
|
|
39754
|
+
since: (t) => blocked.filter((b) => b.at >= t),
|
|
39755
|
+
detach: () => target.unroute("**/*", handler).catch(() => void 0)
|
|
39756
|
+
};
|
|
39757
|
+
}
|
|
39758
|
+
|
|
39509
39759
|
// raw:src/browser/inpage/probe.js
|
|
39510
39760
|
var probe_default = `// Runs inside the audited page. Plain JavaScript (no imports, no bundler helpers) evaluated as
|
|
39511
39761
|
// \`(probe)(options)\`. It only reads the DOM; it never changes the page.
|
|
@@ -40192,6 +40442,28 @@ var ESCAPE_TRAP = `(sels) => {
|
|
|
40192
40442
|
next.focus();
|
|
40193
40443
|
return true;
|
|
40194
40444
|
}`;
|
|
40445
|
+
var TRAP_ADVICE = `(selectors) => {
|
|
40446
|
+
const text = document.body.innerText || '';
|
|
40447
|
+
const m = text.match(/(press|use|hit)\\s+(ctrl|control|alt|option|shift|cmd|command|esc|escape|f\\d+)[^.\\n]{0,24}?\\s+to\\s+(exit|leave|escape|close|quit|move on|continue)[^.\\n]{0,40}/i);
|
|
40448
|
+
if (m) return m[0].trim();
|
|
40449
|
+
for (const s of selectors) {
|
|
40450
|
+
const el = document.querySelector(s);
|
|
40451
|
+
const name = el ? (el.getAttribute('aria-label') || el.textContent || '').trim() : '';
|
|
40452
|
+
if (/\\b(how to|help|exit|leave|skip out)\\b/i.test(name)) return name.slice(0, 80);
|
|
40453
|
+
}
|
|
40454
|
+
return null;
|
|
40455
|
+
}`;
|
|
40456
|
+
async function shotAround(page, r) {
|
|
40457
|
+
const pad = 24;
|
|
40458
|
+
const clip = {
|
|
40459
|
+
x: Math.max(0, r.x - pad),
|
|
40460
|
+
y: Math.max(0, r.y - pad),
|
|
40461
|
+
width: r.width + pad * 2,
|
|
40462
|
+
height: r.height + pad * 2
|
|
40463
|
+
};
|
|
40464
|
+
if (r.width < 1 || r.height < 1 || clip.width * clip.height > 640 * 480) return null;
|
|
40465
|
+
return page.screenshot({ clip, fullPage: true, animations: "disabled", caret: "hide", type: "png" }).catch(() => null);
|
|
40466
|
+
}
|
|
40195
40467
|
async function keyboardWalk(page, maxTabs = 60) {
|
|
40196
40468
|
const focusableCount = await page.evaluate(COUNT_FOCUSABLE);
|
|
40197
40469
|
const stops = [];
|
|
@@ -40202,6 +40474,7 @@ async function keyboardWalk(page, maxTabs = 60) {
|
|
|
40202
40474
|
);
|
|
40203
40475
|
let prevHandle = null;
|
|
40204
40476
|
let prevFocused = null;
|
|
40477
|
+
let prevShot = null;
|
|
40205
40478
|
const limit = Math.min(maxTabs, focusableCount + 12);
|
|
40206
40479
|
const sequence = [];
|
|
40207
40480
|
const newStop = (d) => ({
|
|
@@ -40236,6 +40509,9 @@ async function keyboardWalk(page, maxTabs = 60) {
|
|
|
40236
40509
|
for (let i = 0; i < limit; i++) {
|
|
40237
40510
|
await page.keyboard.press(tabKey);
|
|
40238
40511
|
walk2.pressed++;
|
|
40512
|
+
await page.evaluate(
|
|
40513
|
+
"new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(r, 20))))"
|
|
40514
|
+
).catch(() => void 0);
|
|
40239
40515
|
const handle = await page.evaluateHandle("document.activeElement");
|
|
40240
40516
|
const data = await handle.evaluate(inPage(FOCUS_INFO));
|
|
40241
40517
|
const prevSelector = sequence[sequence.length - 1];
|
|
@@ -40248,12 +40524,24 @@ async function keyboardWalk(page, maxTabs = 60) {
|
|
|
40248
40524
|
walk2.trap = { selectors: [data.selector] };
|
|
40249
40525
|
}
|
|
40250
40526
|
}
|
|
40527
|
+
const isNewStop = !!data && !noMove && !stops.some((s) => s.selector === data.selector);
|
|
40528
|
+
const curFocused = isNewStop ? await handle.evaluate(inPage(FOCUS_STYLES)).catch(() => null) : null;
|
|
40529
|
+
const curShot = isNewStop ? await shotAround(page, data.rect) : null;
|
|
40251
40530
|
if (prevHandle && prevFocused && stops.length) {
|
|
40252
40531
|
const unfocused = await prevHandle.evaluate(inPage(FOCUS_STYLES)).catch(() => null);
|
|
40253
40532
|
const last = stops[stops.length - 1];
|
|
40254
40533
|
if (unfocused) {
|
|
40255
40534
|
last.indicatorChanges = prevFocused.filter((s, k) => s !== unfocused[k]);
|
|
40256
40535
|
last.indicatorChecked = true;
|
|
40536
|
+
if (!last.indicatorChanges.length && prevShot) {
|
|
40537
|
+
await handle.evaluate(inPage("(el) => el.blur && el.blur()")).catch(() => void 0);
|
|
40538
|
+
const neutral = await shotAround(page, last.rect);
|
|
40539
|
+
await handle.evaluate(inPage("(el) => el.focus && el.focus()")).catch(() => void 0);
|
|
40540
|
+
if (neutral && !neutral.equals(prevShot))
|
|
40541
|
+
last.indicatorChanges = [
|
|
40542
|
+
"the area around the element changes on focus (indicator drawn by another element)"
|
|
40543
|
+
];
|
|
40544
|
+
}
|
|
40257
40545
|
if (process.env.JOURILO_DEBUG?.includes("keyboard"))
|
|
40258
40546
|
process.stderr.write(
|
|
40259
40547
|
`[keyboard] ${last.selector} changes=${JSON.stringify(last.indicatorChanges)}
|
|
@@ -40266,6 +40554,7 @@ async function keyboardWalk(page, maxTabs = 60) {
|
|
|
40266
40554
|
break;
|
|
40267
40555
|
}
|
|
40268
40556
|
prevFocused = null;
|
|
40557
|
+
prevShot = null;
|
|
40269
40558
|
if (!data) {
|
|
40270
40559
|
if (stops.length) walk2.completedCycle = true;
|
|
40271
40560
|
break;
|
|
@@ -40286,7 +40575,8 @@ async function keyboardWalk(page, maxTabs = 60) {
|
|
|
40286
40575
|
for (const s of tail) counts.set(s, (counts.get(s) ?? 0) + 1);
|
|
40287
40576
|
const distinct = new Set([...counts].filter(([, n]) => n >= 2).map(([s]) => s));
|
|
40288
40577
|
const cycling = distinct.size > 0 && tail.slice(-4).every((s) => distinct.has(s));
|
|
40289
|
-
|
|
40578
|
+
const unreached = focusableCount - new Set(sequence).size;
|
|
40579
|
+
if (cycling && distinct.size <= 3 && unreached > 0 && !data.inModal) {
|
|
40290
40580
|
walk2.trap = { selectors: [...distinct] };
|
|
40291
40581
|
if (!escaped && await page.evaluate(`(${ESCAPE_TRAP})(${JSON.stringify([...distinct])})`).catch(() => false)) {
|
|
40292
40582
|
escaped = true;
|
|
@@ -40306,12 +40596,653 @@ async function keyboardWalk(page, maxTabs = 60) {
|
|
|
40306
40596
|
prevHandle = null;
|
|
40307
40597
|
continue;
|
|
40308
40598
|
}
|
|
40309
|
-
prevFocused =
|
|
40599
|
+
prevFocused = curFocused;
|
|
40600
|
+
prevShot = curShot;
|
|
40310
40601
|
prevHandle = handle;
|
|
40311
40602
|
stops.push(newStop(data));
|
|
40312
40603
|
}
|
|
40604
|
+
if (walk2.trap) {
|
|
40605
|
+
const advice = await page.evaluate(`(${TRAP_ADVICE})(${JSON.stringify(walk2.trap.selectors)})`).catch(() => null);
|
|
40606
|
+
if (advice) {
|
|
40607
|
+
walk2.trap.advice = advice;
|
|
40608
|
+
const key = adviceKey(advice);
|
|
40609
|
+
if (key) {
|
|
40610
|
+
const trapped = walk2.trap.selectors;
|
|
40611
|
+
await page.locator(trapped[0]).first().focus({ timeout: 2e3 }).catch(() => void 0);
|
|
40612
|
+
await page.keyboard.press(key).catch(() => void 0);
|
|
40613
|
+
await page.evaluate("new Promise((r) => requestAnimationFrame(() => setTimeout(r, 30)))").catch(() => void 0);
|
|
40614
|
+
const out = await page.evaluate(
|
|
40615
|
+
`((sel) => { const a = document.activeElement; return !!a && a !== document.body && !sel.some((s) => a.matches(s)); })(${JSON.stringify(trapped)})`
|
|
40616
|
+
).catch(() => false);
|
|
40617
|
+
walk2.trap.adviceWorks = out;
|
|
40618
|
+
}
|
|
40619
|
+
}
|
|
40620
|
+
}
|
|
40313
40621
|
return walk2;
|
|
40314
40622
|
}
|
|
40623
|
+
function adviceKey(advice) {
|
|
40624
|
+
const m = advice.match(/\b(?:press|use|hit)\s+([a-z0-9]+(?:\s*[+-]\s*[a-z0-9]+)*)/i);
|
|
40625
|
+
if (!m) return null;
|
|
40626
|
+
const NAMES = {
|
|
40627
|
+
ctrl: "Control",
|
|
40628
|
+
control: "Control",
|
|
40629
|
+
alt: "Alt",
|
|
40630
|
+
option: "Alt",
|
|
40631
|
+
shift: "Shift",
|
|
40632
|
+
cmd: "Meta",
|
|
40633
|
+
command: "Meta",
|
|
40634
|
+
esc: "Escape",
|
|
40635
|
+
escape: "Escape",
|
|
40636
|
+
tab: "Tab",
|
|
40637
|
+
enter: "Enter"
|
|
40638
|
+
};
|
|
40639
|
+
const parts = m[1].split(/\s*[+-]\s*/).map(
|
|
40640
|
+
(k) => NAMES[k.toLowerCase()] ?? (/^f\d{1,2}$/i.test(k) ? k.toUpperCase() : k.length === 1 ? k.toUpperCase() : null)
|
|
40641
|
+
);
|
|
40642
|
+
if (parts.some((p) => !p) || !parts.some((p) => p !== "Shift" && !/^(Control|Alt|Meta)$/.test(p) || parts.length === 1))
|
|
40643
|
+
return null;
|
|
40644
|
+
return parts.join("+");
|
|
40645
|
+
}
|
|
40646
|
+
|
|
40647
|
+
// src/core/net-guard.ts
|
|
40648
|
+
import { lookup as dnsLookup } from "node:dns";
|
|
40649
|
+
import http from "node:http";
|
|
40650
|
+
import https from "node:https";
|
|
40651
|
+
import net, { isIP as isIP2 } from "node:net";
|
|
40652
|
+
|
|
40653
|
+
// src/core/url-safety.ts
|
|
40654
|
+
init_errors();
|
|
40655
|
+
import { lookup } from "node:dns/promises";
|
|
40656
|
+
import { isIP } from "node:net";
|
|
40657
|
+
var LOCAL_SUFFIXES = [".localhost", ".test"];
|
|
40658
|
+
function isLocalHostname(host) {
|
|
40659
|
+
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
40660
|
+
return h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h) || h === "0.0.0.0" || LOCAL_SUFFIXES.some((s) => h.endsWith(s));
|
|
40661
|
+
}
|
|
40662
|
+
var METADATA_V4 = /* @__PURE__ */ new Set(["169.254.169.254", "169.254.170.2", "169.254.169.123", "100.100.100.200"]);
|
|
40663
|
+
function classifyV4(v) {
|
|
40664
|
+
const [a, b, c, d] = v.split(".").map(Number);
|
|
40665
|
+
if (METADATA_V4.has(`${a}.${b}.${c}.${d}`)) return "metadata";
|
|
40666
|
+
if (a === 0) return b === 0 && c === 0 && d === 0 ? "loopback" : "reserved";
|
|
40667
|
+
if (a === 127) return "loopback";
|
|
40668
|
+
if (a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168) return "private";
|
|
40669
|
+
if (a === 100 && b >= 64 && b <= 127) return "private";
|
|
40670
|
+
if (a === 169 && b === 254) return "link-local";
|
|
40671
|
+
if (a === 192 && b === 0 && (c === 0 || c === 2)) return "reserved";
|
|
40672
|
+
if (a === 192 && b === 88 && c === 99) return "reserved";
|
|
40673
|
+
if (a === 198 && (b === 18 || b === 19)) return "reserved";
|
|
40674
|
+
if (a === 198 && b === 51 && c === 100) return "reserved";
|
|
40675
|
+
if (a === 203 && b === 0 && c === 113) return "reserved";
|
|
40676
|
+
if (a >= 224) return "reserved";
|
|
40677
|
+
return "public";
|
|
40678
|
+
}
|
|
40679
|
+
function v6Groups(v) {
|
|
40680
|
+
let s = v.split("%")[0];
|
|
40681
|
+
const tail = s.match(/(\d+\.\d+\.\d+\.\d+)$/);
|
|
40682
|
+
if (tail) {
|
|
40683
|
+
const p = tail[1].split(".").map(Number);
|
|
40684
|
+
s = `${s.slice(0, -tail[1].length)}${(p[0] << 8 | p[1]).toString(16)}:${(p[2] << 8 | p[3]).toString(16)}`;
|
|
40685
|
+
}
|
|
40686
|
+
const halves = s.split("::");
|
|
40687
|
+
if (halves.length > 2) return null;
|
|
40688
|
+
const parse2 = (h) => h ? h.split(":").map((g) => Number.parseInt(g, 16)) : [];
|
|
40689
|
+
const head = parse2(halves[0]);
|
|
40690
|
+
const rest = halves.length === 2 ? parse2(halves[1]) : [];
|
|
40691
|
+
const fill = 8 - head.length - rest.length;
|
|
40692
|
+
if (fill < 0 || halves.length === 1 && fill !== 0) return null;
|
|
40693
|
+
const groups = [...head, ...Array(fill).fill(0), ...rest];
|
|
40694
|
+
return groups.every((g) => Number.isInteger(g) && g >= 0 && g <= 65535) ? groups : null;
|
|
40695
|
+
}
|
|
40696
|
+
var v4From = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
40697
|
+
function classifyV6(v) {
|
|
40698
|
+
const g = v6Groups(v);
|
|
40699
|
+
if (!g) return "reserved";
|
|
40700
|
+
const [g0, g1, g2, g3, g4, g5, g6, g7] = g;
|
|
40701
|
+
const zeroTo = (n) => g.slice(0, n).every((x) => x === 0);
|
|
40702
|
+
if (zeroTo(8)) return "loopback";
|
|
40703
|
+
if (zeroTo(7) && g7 === 1) return "loopback";
|
|
40704
|
+
if (zeroTo(5) && g5 === 65535) return classifyV4(v4From(g6, g7));
|
|
40705
|
+
if (zeroTo(6)) return classifyV4(v4From(g6, g7));
|
|
40706
|
+
if (g0 === 100 && g1 === 65435 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0)
|
|
40707
|
+
return classifyV4(v4From(g6, g7));
|
|
40708
|
+
if (g0 === 100 && g1 === 65435 && g2 === 1) return "private";
|
|
40709
|
+
if (g0 === 8194) return classifyV4(v4From(g1, g2));
|
|
40710
|
+
if (g0 === 8193 && g1 === 0) return "reserved";
|
|
40711
|
+
if (g0 === 8193 && g1 === 3512) return "reserved";
|
|
40712
|
+
if (g0 === 256 && g1 === 0 && g2 === 0 && g3 === 0) return "reserved";
|
|
40713
|
+
if (g0 === 64768 && g1 === 3778 && g2 + g3 + g4 + g5 + g6 === 0 && g7 === 596) return "metadata";
|
|
40714
|
+
if ((g0 & 65024) === 64512) return "private";
|
|
40715
|
+
if ((g0 & 65472) === 65152) return "link-local";
|
|
40716
|
+
if ((g0 & 65472) === 65216) return "private";
|
|
40717
|
+
if ((g0 & 65280) === 65280) return "reserved";
|
|
40718
|
+
return "public";
|
|
40719
|
+
}
|
|
40720
|
+
function classifyIp(ip) {
|
|
40721
|
+
const v = ip.replace(/^\[|\]$/g, "").toLowerCase();
|
|
40722
|
+
const family = isIP(v.split("%")[0]);
|
|
40723
|
+
if (family === 4) return classifyV4(v);
|
|
40724
|
+
if (family === 6) return classifyV6(v);
|
|
40725
|
+
return "reserved";
|
|
40726
|
+
}
|
|
40727
|
+
function addressVerdict(ip, policy) {
|
|
40728
|
+
const cls = classifyIp(ip);
|
|
40729
|
+
switch (cls) {
|
|
40730
|
+
case "public":
|
|
40731
|
+
return { ok: true };
|
|
40732
|
+
case "metadata":
|
|
40733
|
+
return {
|
|
40734
|
+
ok: false,
|
|
40735
|
+
reason: `${ip} is a cloud instance-metadata endpoint; Jourilo never connects to it.`
|
|
40736
|
+
};
|
|
40737
|
+
case "reserved":
|
|
40738
|
+
return { ok: false, reason: `${ip} is a reserved or special-purpose address.` };
|
|
40739
|
+
case "loopback":
|
|
40740
|
+
return policy.target === "local" || policy.allowPrivate ? { ok: true } : { ok: false, reason: `${ip} is this machine (loopback); a public target may not reach it.` };
|
|
40741
|
+
default:
|
|
40742
|
+
return policy.allowPrivate ? { ok: true } : {
|
|
40743
|
+
ok: false,
|
|
40744
|
+
reason: `${ip} is a ${cls} network address; pass --allow-private if you are authorized.`
|
|
40745
|
+
};
|
|
40746
|
+
}
|
|
40747
|
+
}
|
|
40748
|
+
function parseTargetUrl(raw) {
|
|
40749
|
+
let url;
|
|
40750
|
+
try {
|
|
40751
|
+
url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
|
|
40752
|
+
} catch {
|
|
40753
|
+
throw new JouriloError("INVALID_INPUT", `Not a valid URL: ${raw}`);
|
|
40754
|
+
}
|
|
40755
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
40756
|
+
throw new JouriloError("UNSAFE_URL", `Only http and https URLs are supported (got ${url.protocol}).`);
|
|
40757
|
+
}
|
|
40758
|
+
url.hash = "";
|
|
40759
|
+
return url;
|
|
40760
|
+
}
|
|
40761
|
+
var systemResolver = (host) => lookup(host, { all: true, verbatim: true });
|
|
40762
|
+
var PRIVATE_HINT = "Pass --allow-private if you are authorized to test this host.";
|
|
40763
|
+
async function checkTarget(url, opts = {}) {
|
|
40764
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
40765
|
+
const literal = isIP(host) !== 0;
|
|
40766
|
+
if (literal) {
|
|
40767
|
+
const cls = classifyIp(host);
|
|
40768
|
+
if (cls === "metadata" || cls === "reserved" && host !== "0.0.0.0")
|
|
40769
|
+
throw new JouriloError("UNSAFE_URL", `${host} is a ${cls} address; Jourilo does not audit it.`);
|
|
40770
|
+
if (cls === "loopback") return "local";
|
|
40771
|
+
if (cls === "public") return "public";
|
|
40772
|
+
if (!opts.allowPrivate)
|
|
40773
|
+
throw new JouriloError("UNSAFE_URL", `${host} is a private network address.`, PRIVATE_HINT);
|
|
40774
|
+
return "private";
|
|
40775
|
+
}
|
|
40776
|
+
if (isLocalHostname(host)) return "local";
|
|
40777
|
+
if (opts.resolveDns === false) return "public";
|
|
40778
|
+
let addrs;
|
|
40779
|
+
try {
|
|
40780
|
+
addrs = await (opts.resolver ?? systemResolver)(host);
|
|
40781
|
+
} catch {
|
|
40782
|
+
return "public";
|
|
40783
|
+
}
|
|
40784
|
+
const classes = addrs.map((a) => ({ address: a.address, cls: classifyIp(a.address) }));
|
|
40785
|
+
const forbidden = classes.find((c) => c.cls === "metadata" || c.cls === "reserved");
|
|
40786
|
+
if (forbidden)
|
|
40787
|
+
throw new JouriloError(
|
|
40788
|
+
"UNSAFE_URL",
|
|
40789
|
+
`${host} resolves to ${forbidden.address}, a ${forbidden.cls} address; Jourilo does not audit it.`
|
|
40790
|
+
);
|
|
40791
|
+
const nonPublic = classes.filter((c) => c.cls !== "public");
|
|
40792
|
+
if (!nonPublic.length) return "public";
|
|
40793
|
+
if (!opts.allowPrivate) {
|
|
40794
|
+
const mixed = nonPublic.length < classes.length;
|
|
40795
|
+
throw new JouriloError(
|
|
40796
|
+
"UNSAFE_URL",
|
|
40797
|
+
mixed ? `${host} resolves to both public and private addresses (${nonPublic.map((c) => c.address).join(", ")}); this is refused because the browser could connect to either (DNS rebinding).` : `${host} resolves to a private network address (${nonPublic[0].address}).`,
|
|
40798
|
+
PRIVATE_HINT
|
|
40799
|
+
);
|
|
40800
|
+
}
|
|
40801
|
+
return "private";
|
|
40802
|
+
}
|
|
40803
|
+
function isCrawlable(candidate, origin) {
|
|
40804
|
+
let u;
|
|
40805
|
+
try {
|
|
40806
|
+
u = new URL(candidate);
|
|
40807
|
+
} catch {
|
|
40808
|
+
return false;
|
|
40809
|
+
}
|
|
40810
|
+
if (u.origin !== origin) return false;
|
|
40811
|
+
if (/\/(log-?out|sign-?out|logoff|delete|destroy|remove|unsubscribe|cancel)(\/|$|\?)/i.test(u.pathname))
|
|
40812
|
+
return false;
|
|
40813
|
+
if (/\.(pdf|zip|gz|dmg|exe|msi|png|jpe?g|gif|webp|svg|mp4|mp3|woff2?|ttf|ico|xml|json|txt|csv)$/i.test(
|
|
40814
|
+
u.pathname
|
|
40815
|
+
))
|
|
40816
|
+
return false;
|
|
40817
|
+
return true;
|
|
40818
|
+
}
|
|
40819
|
+
function normalizeForCrawl(raw) {
|
|
40820
|
+
const u = new URL(raw);
|
|
40821
|
+
u.hash = "";
|
|
40822
|
+
for (const k of [...u.searchParams.keys()])
|
|
40823
|
+
if (/^utm_|^(fbclid|gclid|ref)$/i.test(k)) u.searchParams.delete(k);
|
|
40824
|
+
if (u.pathname !== "/" && u.pathname.endsWith("/")) u.pathname = u.pathname.replace(/\/+$/, "");
|
|
40825
|
+
return u.toString();
|
|
40826
|
+
}
|
|
40827
|
+
|
|
40828
|
+
// src/core/net-guard.ts
|
|
40829
|
+
var GuardBlockedError = class extends Error {
|
|
40830
|
+
code = "EJOURILOBLOCKED";
|
|
40831
|
+
};
|
|
40832
|
+
var systemResolve = (host) => new Promise(
|
|
40833
|
+
(resolve10, reject) => dnsLookup(host, { all: true, verbatim: true }, (err, addrs) => err ? reject(err) : resolve10(addrs))
|
|
40834
|
+
);
|
|
40835
|
+
async function resolveAllowed(host, port, policy, deps = {}) {
|
|
40836
|
+
const h = host.replace(/^\[|\]$/g, "");
|
|
40837
|
+
const verdict = deps.verdict ?? ((ip) => addressVerdict(ip, policy));
|
|
40838
|
+
let addrs;
|
|
40839
|
+
if (isIP2(h)) addrs = [{ address: h, family: isIP2(h) }];
|
|
40840
|
+
else {
|
|
40841
|
+
try {
|
|
40842
|
+
addrs = await (deps.resolve ?? systemResolve)(h);
|
|
40843
|
+
} catch (err) {
|
|
40844
|
+
throw Object.assign(new Error(`DNS lookup failed for ${h}`), { cause: err });
|
|
40845
|
+
}
|
|
40846
|
+
}
|
|
40847
|
+
let reason = "";
|
|
40848
|
+
for (const a of addrs) {
|
|
40849
|
+
const v = verdict(a.address, port);
|
|
40850
|
+
if (v.ok) return { address: a.address, family: a.family };
|
|
40851
|
+
reason ||= v.reason;
|
|
40852
|
+
}
|
|
40853
|
+
return { blocked: reason || `${h} did not resolve to an allowed address.` };
|
|
40854
|
+
}
|
|
40855
|
+
function guardedLookup(policy, port, deps = {}) {
|
|
40856
|
+
return (hostname, options, callback) => {
|
|
40857
|
+
const wantAll = typeof options === "object" && options?.all === true;
|
|
40858
|
+
resolveAllowed(hostname, port, policy, deps).then(
|
|
40859
|
+
(r) => {
|
|
40860
|
+
if ("blocked" in r) return callback(new GuardBlockedError(r.blocked), wantAll ? [] : "", 0);
|
|
40861
|
+
if (wantAll) return callback(null, [{ address: r.address, family: r.family }]);
|
|
40862
|
+
callback(null, r.address, r.family);
|
|
40863
|
+
},
|
|
40864
|
+
(err) => callback(err, wantAll ? [] : "", 0)
|
|
40865
|
+
);
|
|
40866
|
+
};
|
|
40867
|
+
}
|
|
40868
|
+
function splitHostPort(authority, defaultPort) {
|
|
40869
|
+
const m = authority.match(/^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/);
|
|
40870
|
+
if (!m) return null;
|
|
40871
|
+
const port = m[2] ? Number(m[2]) : defaultPort;
|
|
40872
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
40873
|
+
return { host: m[1].replace(/^\[|\]$/g, ""), port };
|
|
40874
|
+
}
|
|
40875
|
+
var HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
40876
|
+
"proxy-connection",
|
|
40877
|
+
"proxy-authorization",
|
|
40878
|
+
"proxy-authenticate",
|
|
40879
|
+
"connection",
|
|
40880
|
+
"keep-alive",
|
|
40881
|
+
"te",
|
|
40882
|
+
"trailer",
|
|
40883
|
+
"transfer-encoding",
|
|
40884
|
+
"upgrade"
|
|
40885
|
+
]);
|
|
40886
|
+
function isLoopbackName(host) {
|
|
40887
|
+
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
40888
|
+
return h === "localhost" || h.endsWith(".localhost") || isIP2(h) !== 0 && classifyIp(h) === "loopback";
|
|
40889
|
+
}
|
|
40890
|
+
function loopbackRedirectVerdict(location, from, policy) {
|
|
40891
|
+
let to;
|
|
40892
|
+
try {
|
|
40893
|
+
to = new URL(location, from);
|
|
40894
|
+
} catch {
|
|
40895
|
+
return null;
|
|
40896
|
+
}
|
|
40897
|
+
if (!isLoopbackName(to.hostname)) return null;
|
|
40898
|
+
if (policy.target === "local" || policy.allowPrivate) return null;
|
|
40899
|
+
return `redirect from ${from.host} to ${to.host} (this machine) refused.`;
|
|
40900
|
+
}
|
|
40901
|
+
function redirectEscape(url, redirectedFrom, policy) {
|
|
40902
|
+
if (!redirectedFrom) return null;
|
|
40903
|
+
try {
|
|
40904
|
+
return loopbackRedirectVerdict(url, new URL(redirectedFrom), policy);
|
|
40905
|
+
} catch {
|
|
40906
|
+
return null;
|
|
40907
|
+
}
|
|
40908
|
+
}
|
|
40909
|
+
async function startNetGuard(policy, deps = {}) {
|
|
40910
|
+
const blocked = [];
|
|
40911
|
+
const seen = /* @__PURE__ */ new Set();
|
|
40912
|
+
const record = (host, port2, reason) => {
|
|
40913
|
+
const key = `${host}:${port2}`;
|
|
40914
|
+
if (seen.has(key) || blocked.length >= 200) return;
|
|
40915
|
+
seen.add(key);
|
|
40916
|
+
blocked.push({ host, port: port2, reason });
|
|
40917
|
+
};
|
|
40918
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
40919
|
+
const track = (s) => {
|
|
40920
|
+
sockets.add(s);
|
|
40921
|
+
s.on("close", () => sockets.delete(s));
|
|
40922
|
+
return s;
|
|
40923
|
+
};
|
|
40924
|
+
const connectUpstream = (host, port2, onReady, onFail) => {
|
|
40925
|
+
resolveAllowed(host, port2, policy, deps).then(
|
|
40926
|
+
(r) => {
|
|
40927
|
+
if ("blocked" in r) {
|
|
40928
|
+
record(host, port2, r.blocked);
|
|
40929
|
+
return onFail(403, r.blocked);
|
|
40930
|
+
}
|
|
40931
|
+
const upstream = track(net.connect({ host: r.address, port: port2 }));
|
|
40932
|
+
upstream.once("connect", () => onReady(upstream));
|
|
40933
|
+
upstream.once("error", (err) => onFail(502, err.message));
|
|
40934
|
+
},
|
|
40935
|
+
(err) => onFail(502, err.message)
|
|
40936
|
+
);
|
|
40937
|
+
};
|
|
40938
|
+
const pipeBoth = (client, upstream, head) => {
|
|
40939
|
+
if (head.length) upstream.write(head);
|
|
40940
|
+
upstream.pipe(client);
|
|
40941
|
+
client.pipe(upstream);
|
|
40942
|
+
upstream.on("error", () => client.destroy());
|
|
40943
|
+
client.on("error", () => upstream.destroy());
|
|
40944
|
+
client.on("close", () => upstream.destroy());
|
|
40945
|
+
upstream.on("close", () => client.destroy());
|
|
40946
|
+
};
|
|
40947
|
+
const server = http.createServer((req, res) => {
|
|
40948
|
+
let target;
|
|
40949
|
+
try {
|
|
40950
|
+
target = new URL(req.url ?? "");
|
|
40951
|
+
if (target.protocol !== "http:") throw new Error("not http");
|
|
40952
|
+
} catch {
|
|
40953
|
+
res.writeHead(400).end("Jourilo network guard: absolute http:// URL expected.");
|
|
40954
|
+
return;
|
|
40955
|
+
}
|
|
40956
|
+
const host = target.hostname.replace(/^\[|\]$/g, "");
|
|
40957
|
+
const port2 = Number(target.port || 80);
|
|
40958
|
+
const headers = {};
|
|
40959
|
+
for (const [k, v] of Object.entries(req.headers))
|
|
40960
|
+
if (v !== void 0 && !HOP_BY_HOP.has(k.toLowerCase())) headers[k] = v;
|
|
40961
|
+
const fail = (status, reason) => {
|
|
40962
|
+
if (res.headersSent) return void res.destroy();
|
|
40963
|
+
res.writeHead(status, { "content-type": "text/plain" }).end(status === 403 ? `Blocked by Jourilo network guard: ${reason}` : "Bad gateway");
|
|
40964
|
+
};
|
|
40965
|
+
resolveAllowed(host, port2, policy, deps).then(
|
|
40966
|
+
(r) => {
|
|
40967
|
+
if ("blocked" in r) {
|
|
40968
|
+
record(host, port2, r.blocked);
|
|
40969
|
+
req.resume();
|
|
40970
|
+
return fail(403, r.blocked);
|
|
40971
|
+
}
|
|
40972
|
+
const upstream = http.request({
|
|
40973
|
+
host: r.address,
|
|
40974
|
+
port: port2,
|
|
40975
|
+
method: req.method,
|
|
40976
|
+
path: `${target.pathname}${target.search}`,
|
|
40977
|
+
headers,
|
|
40978
|
+
setHost: false
|
|
40979
|
+
});
|
|
40980
|
+
upstream.on("response", (up) => {
|
|
40981
|
+
const loc = (up.statusCode ?? 0) >= 300 && (up.statusCode ?? 0) < 400 ? up.headers.location : void 0;
|
|
40982
|
+
const refused = loc ? loopbackRedirectVerdict(loc, target, policy) : null;
|
|
40983
|
+
if (refused) {
|
|
40984
|
+
up.resume();
|
|
40985
|
+
record(host, port2, refused);
|
|
40986
|
+
return fail(403, refused);
|
|
40987
|
+
}
|
|
40988
|
+
const out = {};
|
|
40989
|
+
for (const [k, v] of Object.entries(up.headers))
|
|
40990
|
+
if (v !== void 0 && !HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
|
|
40991
|
+
res.writeHead(up.statusCode ?? 502, up.statusMessage, out);
|
|
40992
|
+
up.pipe(res);
|
|
40993
|
+
});
|
|
40994
|
+
upstream.on("error", (err) => fail(502, err.message));
|
|
40995
|
+
req.pipe(upstream);
|
|
40996
|
+
},
|
|
40997
|
+
(err) => fail(502, err.message)
|
|
40998
|
+
);
|
|
40999
|
+
});
|
|
41000
|
+
server.on("connect", (req, client, head) => {
|
|
41001
|
+
track(client);
|
|
41002
|
+
client.on("error", () => void 0);
|
|
41003
|
+
const hp = splitHostPort(req.url ?? "", 443);
|
|
41004
|
+
if (!hp) {
|
|
41005
|
+
client.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
41006
|
+
return;
|
|
41007
|
+
}
|
|
41008
|
+
connectUpstream(
|
|
41009
|
+
hp.host,
|
|
41010
|
+
hp.port,
|
|
41011
|
+
(upstream) => {
|
|
41012
|
+
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
41013
|
+
pipeBoth(client, upstream, head);
|
|
41014
|
+
},
|
|
41015
|
+
(status, reason) => client.end(
|
|
41016
|
+
`HTTP/1.1 ${status} ${status === 403 ? "Forbidden" : "Bad Gateway"}\r
|
|
41017
|
+
content-type: text/plain\r
|
|
41018
|
+
\r
|
|
41019
|
+
${status === 403 ? `Blocked by Jourilo network guard: ${reason}` : ""}`
|
|
41020
|
+
)
|
|
41021
|
+
);
|
|
41022
|
+
});
|
|
41023
|
+
server.on("upgrade", (req, client, head) => {
|
|
41024
|
+
track(client);
|
|
41025
|
+
client.on("error", () => void 0);
|
|
41026
|
+
let target;
|
|
41027
|
+
try {
|
|
41028
|
+
target = new URL(req.url ?? "");
|
|
41029
|
+
} catch {
|
|
41030
|
+
client.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
41031
|
+
return;
|
|
41032
|
+
}
|
|
41033
|
+
connectUpstream(
|
|
41034
|
+
target.hostname,
|
|
41035
|
+
Number(target.port || 80),
|
|
41036
|
+
(upstream) => {
|
|
41037
|
+
const lines = [`${req.method} ${target.pathname}${target.search} HTTP/1.1`];
|
|
41038
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
41039
|
+
const k = req.rawHeaders[i];
|
|
41040
|
+
if (/^proxy-/i.test(k)) continue;
|
|
41041
|
+
lines.push(`${k}: ${req.rawHeaders[i + 1]}`);
|
|
41042
|
+
}
|
|
41043
|
+
upstream.write(`${lines.join("\r\n")}\r
|
|
41044
|
+
\r
|
|
41045
|
+
`);
|
|
41046
|
+
pipeBoth(client, upstream, head);
|
|
41047
|
+
},
|
|
41048
|
+
(status) => client.end(`HTTP/1.1 ${status} ${status === 403 ? "Forbidden" : "Bad Gateway"}\r
|
|
41049
|
+
\r
|
|
41050
|
+
`)
|
|
41051
|
+
);
|
|
41052
|
+
});
|
|
41053
|
+
server.on("connection", (s) => track(s));
|
|
41054
|
+
server.on("clientError", (_err, socket) => socket.destroy());
|
|
41055
|
+
await new Promise((resolve10, reject) => {
|
|
41056
|
+
server.once("error", reject);
|
|
41057
|
+
server.listen(0, "127.0.0.1", () => resolve10());
|
|
41058
|
+
});
|
|
41059
|
+
const { port } = server.address();
|
|
41060
|
+
return {
|
|
41061
|
+
url: `http://127.0.0.1:${port}`,
|
|
41062
|
+
policy,
|
|
41063
|
+
blocked,
|
|
41064
|
+
close: () => new Promise((resolve10) => {
|
|
41065
|
+
for (const s of sockets) s.destroy();
|
|
41066
|
+
server.close(() => resolve10());
|
|
41067
|
+
})
|
|
41068
|
+
};
|
|
41069
|
+
}
|
|
41070
|
+
async function guardedFetchText(url, policy, opts) {
|
|
41071
|
+
const origin = new URL(url).origin;
|
|
41072
|
+
let current = new URL(url);
|
|
41073
|
+
for (let hop = 0; hop < 6; hop++) {
|
|
41074
|
+
if (current.origin !== origin) return null;
|
|
41075
|
+
const port = Number(current.port || (current.protocol === "https:" ? 443 : 80));
|
|
41076
|
+
const pre = await resolveAllowed(current.hostname, port, policy, opts.deps).catch(() => null);
|
|
41077
|
+
if (!pre || "blocked" in pre) return null;
|
|
41078
|
+
const res = await new Promise((resolve10) => {
|
|
41079
|
+
const mod = current.protocol === "https:" ? https : http;
|
|
41080
|
+
const req = mod.get(
|
|
41081
|
+
current,
|
|
41082
|
+
{
|
|
41083
|
+
headers: { "user-agent": opts.userAgent, accept: "text/plain, application/xml, text/xml, */*" },
|
|
41084
|
+
lookup: guardedLookup(policy, port, opts.deps),
|
|
41085
|
+
timeout: opts.timeoutMs,
|
|
41086
|
+
agent: false
|
|
41087
|
+
},
|
|
41088
|
+
(r) => {
|
|
41089
|
+
const status = r.statusCode ?? 0;
|
|
41090
|
+
if (status >= 300 && status < 400 && r.headers.location) {
|
|
41091
|
+
r.resume();
|
|
41092
|
+
return resolve10({ status, location: r.headers.location });
|
|
41093
|
+
}
|
|
41094
|
+
if (status < 200 || status >= 300) {
|
|
41095
|
+
r.resume();
|
|
41096
|
+
return resolve10({ status });
|
|
41097
|
+
}
|
|
41098
|
+
const max = opts.maxBytes ?? 2e6;
|
|
41099
|
+
let size = 0;
|
|
41100
|
+
const chunks = [];
|
|
41101
|
+
r.on("data", (c) => {
|
|
41102
|
+
size += c.length;
|
|
41103
|
+
if (size <= max) chunks.push(c);
|
|
41104
|
+
else r.destroy();
|
|
41105
|
+
});
|
|
41106
|
+
const done = () => resolve10({ status, body: Buffer.concat(chunks).toString("utf8") });
|
|
41107
|
+
r.on("end", done);
|
|
41108
|
+
r.on("close", done);
|
|
41109
|
+
r.on("error", () => resolve10(null));
|
|
41110
|
+
}
|
|
41111
|
+
);
|
|
41112
|
+
req.on("timeout", () => req.destroy());
|
|
41113
|
+
req.on("error", () => resolve10(null));
|
|
41114
|
+
});
|
|
41115
|
+
if (!res) return null;
|
|
41116
|
+
if (res.location) {
|
|
41117
|
+
try {
|
|
41118
|
+
current = new URL(res.location, current);
|
|
41119
|
+
} catch {
|
|
41120
|
+
return null;
|
|
41121
|
+
}
|
|
41122
|
+
continue;
|
|
41123
|
+
}
|
|
41124
|
+
return res.body ?? null;
|
|
41125
|
+
}
|
|
41126
|
+
return null;
|
|
41127
|
+
}
|
|
41128
|
+
|
|
41129
|
+
// src/browser/runtime.ts
|
|
41130
|
+
init_errors();
|
|
41131
|
+
import { spawnSync } from "node:child_process";
|
|
41132
|
+
import { createRequire } from "node:module";
|
|
41133
|
+
import { dirname as dirname4, join as join4 } from "node:path";
|
|
41134
|
+
var cached;
|
|
41135
|
+
async function loadPlaywright() {
|
|
41136
|
+
if (cached) return cached;
|
|
41137
|
+
try {
|
|
41138
|
+
cached = await import("playwright-core");
|
|
41139
|
+
return cached;
|
|
41140
|
+
} catch {
|
|
41141
|
+
throw new JouriloError(
|
|
41142
|
+
"BROWSER_UNAVAILABLE",
|
|
41143
|
+
"playwright-core is not installed.",
|
|
41144
|
+
"Run `npm install` in the Jourilo directory (or `npx jourilo setup`)."
|
|
41145
|
+
);
|
|
41146
|
+
}
|
|
41147
|
+
}
|
|
41148
|
+
async function launchBrowser(name = "chromium", headed = false, guard) {
|
|
41149
|
+
const pw = await loadPlaywright();
|
|
41150
|
+
const type = name === "firefox" ? pw.firefox : name === "webkit" ? pw.webkit : pw.chromium;
|
|
41151
|
+
const opts = { headless: !headed };
|
|
41152
|
+
if (name === "chrome" || name === "msedge") opts.channel = name;
|
|
41153
|
+
if (guard) {
|
|
41154
|
+
if (name === "firefox") {
|
|
41155
|
+
opts.proxy = { server: guard.url };
|
|
41156
|
+
opts.firefoxUserPrefs = {
|
|
41157
|
+
"network.proxy.allow_hijacking_localhost": true,
|
|
41158
|
+
"network.proxy.no_proxies_on": "",
|
|
41159
|
+
"media.peerconnection.enabled": false,
|
|
41160
|
+
"network.dns.disablePrefetch": true
|
|
41161
|
+
};
|
|
41162
|
+
} else if (name === "webkit") {
|
|
41163
|
+
opts.proxy = { server: guard.url };
|
|
41164
|
+
} else {
|
|
41165
|
+
opts.proxy = { server: guard.url, bypass: "<-loopback>" };
|
|
41166
|
+
opts.args = [
|
|
41167
|
+
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
41168
|
+
"--webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
41169
|
+
"--disable-quic"
|
|
41170
|
+
];
|
|
41171
|
+
}
|
|
41172
|
+
}
|
|
41173
|
+
try {
|
|
41174
|
+
return await type.launch(opts);
|
|
41175
|
+
} catch (err) {
|
|
41176
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
41177
|
+
if (/Executable doesn't exist|browserType\.launch: .*not found|install/i.test(msg)) {
|
|
41178
|
+
throw new JouriloError(
|
|
41179
|
+
"BROWSER_UNAVAILABLE",
|
|
41180
|
+
`The ${name} browser is not installed for Playwright.`,
|
|
41181
|
+
name === "chrome" || name === "msedge" ? `Install ${name === "chrome" ? "Google Chrome" : "Microsoft Edge"}, or use --browser chromium.` : `Run \`jourilo setup --browser ${name}\` (downloads the browser build Playwright needs), or use --browser chrome to drive an installed Google Chrome.`
|
|
41182
|
+
);
|
|
41183
|
+
}
|
|
41184
|
+
throw new JouriloError("BROWSER_UNAVAILABLE", `Could not launch ${name}: ${msg.split("\n")[0]}`);
|
|
41185
|
+
}
|
|
41186
|
+
}
|
|
41187
|
+
function installBrowser(name) {
|
|
41188
|
+
const require2 = createRequire(import.meta.url);
|
|
41189
|
+
let cli;
|
|
41190
|
+
try {
|
|
41191
|
+
cli = join4(dirname4(require2.resolve("playwright-core/package.json")), "cli.js");
|
|
41192
|
+
} catch {
|
|
41193
|
+
throw new JouriloError(
|
|
41194
|
+
"BROWSER_UNAVAILABLE",
|
|
41195
|
+
"playwright-core is not installed; cannot install browsers."
|
|
41196
|
+
);
|
|
41197
|
+
}
|
|
41198
|
+
const res = spawnSync(process.execPath, [cli, "install", name], { encoding: "utf8" });
|
|
41199
|
+
return { ok: res.status === 0, output: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() };
|
|
41200
|
+
}
|
|
41201
|
+
|
|
41202
|
+
// src/browser/session.ts
|
|
41203
|
+
var sessions = /* @__PURE__ */ new WeakMap();
|
|
41204
|
+
async function openGuardedBrowser(name, headed, policy) {
|
|
41205
|
+
const guard = await startNetGuard(policy);
|
|
41206
|
+
let browser;
|
|
41207
|
+
try {
|
|
41208
|
+
browser = await launchBrowser(name, headed, guard);
|
|
41209
|
+
} catch (err) {
|
|
41210
|
+
await guard.close();
|
|
41211
|
+
throw err;
|
|
41212
|
+
}
|
|
41213
|
+
const escaped = [];
|
|
41214
|
+
sessions.set(browser, { policy, escaped });
|
|
41215
|
+
return {
|
|
41216
|
+
browser,
|
|
41217
|
+
guard,
|
|
41218
|
+
report: () => ({ blocked: [...guard.blocked], escaped: [...escaped] }),
|
|
41219
|
+
close: async () => {
|
|
41220
|
+
await browser.close().catch(() => void 0);
|
|
41221
|
+
await guard.close();
|
|
41222
|
+
}
|
|
41223
|
+
};
|
|
41224
|
+
}
|
|
41225
|
+
function watchContext(browser, ctx) {
|
|
41226
|
+
const s = sessions.get(browser);
|
|
41227
|
+
if (!s) return;
|
|
41228
|
+
ctx.on("request", (req) => {
|
|
41229
|
+
const from = req.redirectedFrom()?.url() ?? null;
|
|
41230
|
+
const reason = redirectEscape(req.url(), from, s.policy);
|
|
41231
|
+
if (reason && s.escaped.length < 50) s.escaped.push({ url: redactUrl(req.url()), reason });
|
|
41232
|
+
});
|
|
41233
|
+
}
|
|
41234
|
+
function networkLimitations(r) {
|
|
41235
|
+
const out = [];
|
|
41236
|
+
if (r.blocked.length)
|
|
41237
|
+
out.push(
|
|
41238
|
+
`The network guard refused ${r.blocked.length} connection(s) to non-public addresses (${r.blocked.slice(0, 3).map((b) => `${b.host}:${b.port}`).join(", ")}${r.blocked.length > 3 ? ", \u2026" : ""}); content that depends on them was not loaded.`
|
|
41239
|
+
);
|
|
41240
|
+
if (r.escaped.length)
|
|
41241
|
+
out.push(
|
|
41242
|
+
`SECURITY: ${r.escaped.length} redirect(s) from the target reached this machine outside the network guard (${r.escaped[0].url}). This browser follows HTTPS redirects to loopback addresses directly; use Chromium or Firefox for untrusted sites.`
|
|
41243
|
+
);
|
|
41244
|
+
return out;
|
|
41245
|
+
}
|
|
40315
41246
|
|
|
40316
41247
|
// src/browser/vitals.ts
|
|
40317
41248
|
var VITALS_INIT_SCRIPT = `(() => {
|
|
@@ -40409,6 +41340,11 @@ function toMetrics(v, spread, runs2 = 1) {
|
|
|
40409
41340
|
}
|
|
40410
41341
|
const rounded = unit === "score" ? Math.round(value * 1e3) / 1e3 : Math.round(value);
|
|
40411
41342
|
const m = { name, value: rounded, unit, source: "lab" };
|
|
41343
|
+
if (runs2 > 1 && range) {
|
|
41344
|
+
const r2 = (x) => unit === "score" ? Math.round(x * 1e3) / 1e3 : Math.round(x);
|
|
41345
|
+
m.runs = runs2;
|
|
41346
|
+
m.range = [r2(range[0]), r2(range[1])];
|
|
41347
|
+
}
|
|
40412
41348
|
const r = rate(name, rounded);
|
|
40413
41349
|
if (r) m.rating = r;
|
|
40414
41350
|
if (note) m.note = note;
|
|
@@ -40428,56 +41364,6 @@ function toMetrics(v, spread, runs2 = 1) {
|
|
|
40428
41364
|
return out;
|
|
40429
41365
|
}
|
|
40430
41366
|
|
|
40431
|
-
// src/journeys/safety.ts
|
|
40432
|
-
var DESTRUCTIVE = lexicon("destructive");
|
|
40433
|
-
var FINANCIAL = lexicon("financial");
|
|
40434
|
-
function checkAction(ctx, policy) {
|
|
40435
|
-
const safeHost = policy.hostClass === "local";
|
|
40436
|
-
const risky = DESTRUCTIVE.test(ctx.name) ? "destructive" : FINANCIAL.test(ctx.name) || ctx.paymentForm ? "financial" : null;
|
|
40437
|
-
if (risky) {
|
|
40438
|
-
if (!safeHost)
|
|
40439
|
-
return {
|
|
40440
|
-
allowed: false,
|
|
40441
|
-
reason: `"${ctx.name}" looks ${risky === "financial" ? "like a payment/purchase" : "irreversible"}; Jourilo never performs it on a non-local host.`
|
|
40442
|
-
};
|
|
40443
|
-
if (!policy.allowDestructive)
|
|
40444
|
-
return {
|
|
40445
|
-
allowed: false,
|
|
40446
|
-
reason: `"${ctx.name}" looks ${risky === "financial" ? "like a payment/purchase" : "irreversible"}; rerun with --allow-destructive on a local/test environment with sandbox data to include it.`
|
|
40447
|
-
};
|
|
40448
|
-
}
|
|
40449
|
-
if (ctx.submitsForm && !safeHost && !policy.allowSubmit) {
|
|
40450
|
-
return {
|
|
40451
|
-
allowed: false,
|
|
40452
|
-
reason: "Submitting a form on a non-local host is blocked; use --allow-submit only on an environment you are authorized to test, with test accounts."
|
|
40453
|
-
};
|
|
40454
|
-
}
|
|
40455
|
-
return { allowed: true };
|
|
40456
|
-
}
|
|
40457
|
-
function testData(runId, vars = {}) {
|
|
40458
|
-
const tag = runId.replace(/[^a-z0-9]/gi, "").slice(-10).toLowerCase();
|
|
40459
|
-
const base2 = {
|
|
40460
|
-
email: `jourilo+${tag}@example.test`,
|
|
40461
|
-
password: `Jr-${tag}-Q7!x`,
|
|
40462
|
-
name: "Jordan Test",
|
|
40463
|
-
firstName: "Jordan",
|
|
40464
|
-
lastName: "Test",
|
|
40465
|
-
phone: "+1 555 0100",
|
|
40466
|
-
company: "Example Test Co",
|
|
40467
|
-
search: "test",
|
|
40468
|
-
zip: "10001",
|
|
40469
|
-
// Published test card from Stripe's docs; only useful in a provider's test mode.
|
|
40470
|
-
testCard: "4242 4242 4242 4242"
|
|
40471
|
-
};
|
|
40472
|
-
return { ...base2, ...vars };
|
|
40473
|
-
}
|
|
40474
|
-
function interpolate(value, data) {
|
|
40475
|
-
return value.replace(/\{\{\s*([A-Za-z][\w]*)\s*\}\}/g, (m, k) => data[k] ?? m);
|
|
40476
|
-
}
|
|
40477
|
-
function isSecretValue(raw) {
|
|
40478
|
-
return /\{\{\s*(password|testCard|secret|token)\s*\}\}/i.test(raw);
|
|
40479
|
-
}
|
|
40480
|
-
|
|
40481
41367
|
// src/browser/widgets.ts
|
|
40482
41368
|
var AVOID = /(?<![\p{L}])(add to (cart|basket|bag)|sepete ekle|in den warenkorb|añadir al carrito|ajouter au panier|like|beğen|follow|takip et|vote|oy ver|log ?out|sign ?out|çıkış yap|abmelden|cerrar sesión|déconnexion)(?![\p{L}])/iu;
|
|
40483
41369
|
var TRIGGERS = `(() => {
|
|
@@ -40494,6 +41380,16 @@ var TRIGGERS = `(() => {
|
|
|
40494
41380
|
};
|
|
40495
41381
|
return Array.from(document.querySelectorAll('[aria-haspopup]:not([aria-haspopup="false"]), [aria-expanded], [aria-controls], a[href="#"], a[href^="#"]:not([href="#main"]):not([href^="#content"]), a[href^="javascript:"]'))
|
|
40496
41382
|
.filter((e) => e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden' && !e.disabled && /^(BUTTON|A|SUMMARY)$|^button$/i.test(e.tagName === 'DIV' || e.tagName === 'SPAN' ? (e.getAttribute('role') || '') : e.tagName))
|
|
41383
|
+
// A link to a section that is already on screen is in-page navigation (skip links, table of contents), not a
|
|
41384
|
+
// popup trigger; following it only scrolls.
|
|
41385
|
+
.filter((e) => {
|
|
41386
|
+
const href = e.tagName === 'A' ? e.getAttribute('href') || '' : '';
|
|
41387
|
+
if (!/^#./.test(href) || e.hasAttribute('aria-haspopup') || e.hasAttribute('aria-expanded') || e.hasAttribute('aria-controls')) return true;
|
|
41388
|
+
let id = href.slice(1);
|
|
41389
|
+
try { id = decodeURIComponent(id); } catch {}
|
|
41390
|
+
const target = document.getElementById(id) || document.getElementsByName(id)[0];
|
|
41391
|
+
return !(target && target.getClientRects().length > 0 && getComputedStyle(target).visibility !== 'hidden');
|
|
41392
|
+
})
|
|
40497
41393
|
.slice(0, 8)
|
|
40498
41394
|
.map((e) => {
|
|
40499
41395
|
const r = e.getBoundingClientRect();
|
|
@@ -40515,11 +41411,17 @@ var SNAPSHOT_LAYERS = `(() => {
|
|
|
40515
41411
|
return (cs.position === 'fixed' || cs.position === 'absolute') && e.getClientRects().length > 0 && cs.visibility !== 'hidden' && big(e);
|
|
40516
41412
|
});
|
|
40517
41413
|
// Signatures, not node identity: frameworks that re-render replace nodes without anything new appearing.
|
|
40518
|
-
|
|
41414
|
+
// Page coordinates for absolutely positioned layers, so scrolling does not make an existing layer look new.
|
|
41415
|
+
const sig = (e) => { const r = e.getBoundingClientRect(); const fixed = getComputedStyle(e).position === 'fixed'; return e.tagName + '.' + String(e.className) + '|' + Math.round(r.left + (fixed ? 0 : scrollX)) + ',' + Math.round(r.top + (fixed ? 0 : scrollY)) + ',' + Math.round(r.width) + ',' + Math.round(r.height); };
|
|
40519
41416
|
window.__jouriloLayers = new Set(layers.map(sig));
|
|
40520
41417
|
})()`;
|
|
40521
|
-
var STATE = `(controls) => {
|
|
40522
|
-
const visible = (e) => !!e && e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden';
|
|
41418
|
+
var STATE = `(controls, mode) => {
|
|
41419
|
+
const visible = (e) => !!e && e.isConnected && e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden';
|
|
41420
|
+
if (mode === 'same') {
|
|
41421
|
+
const d = window.__jouriloDialog;
|
|
41422
|
+
const ae = document.activeElement;
|
|
41423
|
+
return { dialogOpen: visible(d) && (d.tagName !== 'DIALOG' || d.open), unlabelled: false, focusInDialog: !!d && !!ae && d.contains(ae), focusables: 0, controlledVisible: null, activeSelector: ae && ae !== document.body ? (ae.id ? '#' + ae.id : ae.tagName.toLowerCase()) : null };
|
|
41424
|
+
}
|
|
40523
41425
|
const dialogs = Array.from(document.querySelectorAll('[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"]')).filter(visible);
|
|
40524
41426
|
let d = dialogs[dialogs.length - 1] || null;
|
|
40525
41427
|
let unlabelled = false;
|
|
@@ -40528,12 +41430,14 @@ var STATE = `(controls) => {
|
|
|
40528
41430
|
const fresh = Array.from(document.querySelectorAll('body *')).filter((e) => {
|
|
40529
41431
|
const cs = getComputedStyle(e);
|
|
40530
41432
|
const r = e.getBoundingClientRect();
|
|
40531
|
-
const
|
|
41433
|
+
const fixed = cs.position === 'fixed';
|
|
41434
|
+
const sig = e.tagName + '.' + String(e.className) + '|' + Math.round(r.left + (fixed ? 0 : scrollX)) + ',' + Math.round(r.top + (fixed ? 0 : scrollY)) + ',' + Math.round(r.width) + ',' + Math.round(r.height);
|
|
40532
41435
|
return (cs.position === 'fixed' || cs.position === 'absolute') && visible(e) && big(e) && !window.__jouriloLayers.has(sig);
|
|
40533
41436
|
});
|
|
40534
41437
|
const outer = fresh.filter((e) => !fresh.some((o) => o !== e && o.contains(e)));
|
|
40535
41438
|
if (outer.length) { d = outer[outer.length - 1]; unlabelled = true; }
|
|
40536
41439
|
}
|
|
41440
|
+
window.__jouriloDialog = d;
|
|
40537
41441
|
const ae = document.activeElement;
|
|
40538
41442
|
const controlled = controls ? controls.split(/\\s+/).map((id) => document.getElementById(id)).filter(Boolean) : [];
|
|
40539
41443
|
const focusables = d ? Array.from(d.querySelectorAll('a[href], button, input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])')).filter(visible).length : 0;
|
|
@@ -40546,10 +41450,89 @@ var STATE = `(controls) => {
|
|
|
40546
41450
|
activeSelector: ae && ae !== document.body ? (ae.id ? '#' + ae.id : ae.tagName.toLowerCase()) : null,
|
|
40547
41451
|
};
|
|
40548
41452
|
}`;
|
|
41453
|
+
var COMPOSITES = `(() => {
|
|
41454
|
+
const ITEM = { tablist: 'tab', radiogroup: 'radio', listbox: 'option', menubar: 'menuitem, [role="menuitemcheckbox"], [role="menuitemradio"]', tree: 'treeitem' };
|
|
41455
|
+
const visible = (e) => e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden';
|
|
41456
|
+
const tabbable = (e) => e.tabIndex >= 0 && !e.disabled && e.getAttribute('aria-disabled') !== 'true' && visible(e);
|
|
41457
|
+
const out = [];
|
|
41458
|
+
for (const [role, item] of Object.entries(ITEM)) {
|
|
41459
|
+
for (const c of document.querySelectorAll('[role="' + role + '"]')) {
|
|
41460
|
+
if (!visible(c) || out.length >= 6) continue;
|
|
41461
|
+
const items = Array.from(c.querySelectorAll(item.split(',').map((s) => s.trim().startsWith('[') ? s : '[role="' + s.trim() + '"]').join(','))).filter(visible);
|
|
41462
|
+
if (items.length < 2) continue;
|
|
41463
|
+
const stops = items.filter(tabbable);
|
|
41464
|
+
const containerStop = tabbable(c) && c.hasAttribute('aria-activedescendant');
|
|
41465
|
+
if (stops.length > 1 || (!stops.length && !containerStop)) continue;
|
|
41466
|
+
const id = 'jourilo-composite-' + out.length;
|
|
41467
|
+
c.setAttribute('data-jourilo-composite', id);
|
|
41468
|
+
if (stops[0]) stops[0].setAttribute('data-jourilo-composite-stop', id);
|
|
41469
|
+
const r = c.getBoundingClientRect();
|
|
41470
|
+
const orientation = c.getAttribute('aria-orientation') || (role === 'listbox' || role === 'tree' ? 'vertical' : 'horizontal');
|
|
41471
|
+
out.push({ id, role, orientation, items: items.length, name: (c.getAttribute('aria-label') || items.map((i) => i.innerText.trim()).filter(Boolean).slice(0, 3).join(' / ')).replace(/\\s+/g, ' ').slice(0, 80), rect: { x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), width: Math.round(r.width), height: Math.round(r.height) } });
|
|
41472
|
+
}
|
|
41473
|
+
}
|
|
41474
|
+
return out;
|
|
41475
|
+
})()`;
|
|
41476
|
+
var COMPOSITE_STATE = `(id) => {
|
|
41477
|
+
const c = document.querySelector('[data-jourilo-composite="' + id + '"]');
|
|
41478
|
+
if (!c) return null;
|
|
41479
|
+
const ae = document.activeElement;
|
|
41480
|
+
const marks = Array.from(c.querySelectorAll('[aria-selected], [aria-checked]')).map((e) => (e.getAttribute('aria-selected') || '') + (e.getAttribute('aria-checked') || '')).join('');
|
|
41481
|
+
return { focusIn: !!ae && c.contains(ae), active: ae ? (ae.id || ae.textContent || '').trim().slice(0, 40) + '#' + Array.from(c.querySelectorAll('*')).indexOf(ae) : '', descendant: c.getAttribute('aria-activedescendant') || '', marks };
|
|
41482
|
+
}`;
|
|
41483
|
+
async function checkComposites(page, out) {
|
|
41484
|
+
const found = await page.evaluate(COMPOSITES).catch(() => []);
|
|
41485
|
+
for (const c of found) {
|
|
41486
|
+
const stop = page.locator(`[data-jourilo-composite-stop="${c.id}"]`);
|
|
41487
|
+
const target = await stop.count() ? stop.first() : page.locator(`[data-jourilo-composite="${c.id}"]`);
|
|
41488
|
+
const read3 = () => page.evaluate(`(${COMPOSITE_STATE})(${JSON.stringify(c.id)})`);
|
|
41489
|
+
try {
|
|
41490
|
+
await target.focus({ timeout: 2e3 });
|
|
41491
|
+
} catch {
|
|
41492
|
+
continue;
|
|
41493
|
+
}
|
|
41494
|
+
const before = await read3();
|
|
41495
|
+
if (!before?.focusIn) continue;
|
|
41496
|
+
out.composites++;
|
|
41497
|
+
const keys = c.orientation === "vertical" ? ["ArrowDown", "ArrowRight"] : ["ArrowRight", "ArrowDown"];
|
|
41498
|
+
let moved = false;
|
|
41499
|
+
for (const key of keys) {
|
|
41500
|
+
await page.keyboard.press(key);
|
|
41501
|
+
await page.waitForTimeout(120);
|
|
41502
|
+
const after = await read3();
|
|
41503
|
+
if (after && (after.active !== before.active || after.descendant !== before.descendant || after.marks !== before.marks)) {
|
|
41504
|
+
moved = true;
|
|
41505
|
+
break;
|
|
41506
|
+
}
|
|
41507
|
+
}
|
|
41508
|
+
if (!moved)
|
|
41509
|
+
out.issues.push({
|
|
41510
|
+
kind: "composite-unreachable",
|
|
41511
|
+
trigger: `[role="${c.role}"]`,
|
|
41512
|
+
name: c.name || c.role,
|
|
41513
|
+
rect: c.rect,
|
|
41514
|
+
detail: `Only one of the ${c.items} items of this ${c.role} is in the Tab order, and the arrow keys do not move to the others, so ${c.items - 1} item(s) cannot be reached with a keyboard.`,
|
|
41515
|
+
steps: [
|
|
41516
|
+
`Press Tab until the ${c.role} "${c.name || c.role}" has focus.`,
|
|
41517
|
+
`Press ${keys.join(" and ")}: focus and selection stay where they are.`
|
|
41518
|
+
]
|
|
41519
|
+
});
|
|
41520
|
+
}
|
|
41521
|
+
await page.evaluate(
|
|
41522
|
+
`document.querySelectorAll('[data-jourilo-composite],[data-jourilo-composite-stop]').forEach((e) => { e.removeAttribute('data-jourilo-composite'); e.removeAttribute('data-jourilo-composite-stop'); })`
|
|
41523
|
+
).catch(() => void 0);
|
|
41524
|
+
}
|
|
40549
41525
|
async function checkWidgets(page) {
|
|
40550
41526
|
const triggers = await page.evaluate(TRIGGERS);
|
|
40551
|
-
const out = {
|
|
40552
|
-
|
|
41527
|
+
const out = {
|
|
41528
|
+
triggers: triggers.length,
|
|
41529
|
+
dialogs: 0,
|
|
41530
|
+
disclosures: 0,
|
|
41531
|
+
composites: 0,
|
|
41532
|
+
issues: []
|
|
41533
|
+
};
|
|
41534
|
+
await checkComposites(page, out);
|
|
41535
|
+
const state = (controls, mode = "open") => page.evaluate(`(${STATE})(${JSON.stringify(controls)}, ${JSON.stringify(mode)})`);
|
|
40553
41536
|
for (const t of triggers) {
|
|
40554
41537
|
if (DESTRUCTIVE.test(t.name) || FINANCIAL.test(t.name) || AVOID.test(t.name)) continue;
|
|
40555
41538
|
const loc = page.locator(t.selector).first();
|
|
@@ -40580,7 +41563,7 @@ async function checkWidgets(page) {
|
|
|
40580
41563
|
);
|
|
40581
41564
|
} else {
|
|
40582
41565
|
for (let k = 0; k <= after.focusables; k++) await page.keyboard.press("Tab");
|
|
40583
|
-
const tabbed = await state(t.controls);
|
|
41566
|
+
const tabbed = await state(t.controls, "same");
|
|
40584
41567
|
if (tabbed.dialogOpen && !tabbed.focusInDialog)
|
|
40585
41568
|
issue(
|
|
40586
41569
|
"dialog-focus-escapes",
|
|
@@ -40589,7 +41572,7 @@ async function checkWidgets(page) {
|
|
|
40589
41572
|
}
|
|
40590
41573
|
await page.keyboard.press("Escape");
|
|
40591
41574
|
await page.waitForTimeout(300);
|
|
40592
|
-
const closed = await state(t.controls);
|
|
41575
|
+
const closed = await state(t.controls, "same");
|
|
40593
41576
|
if (closed.dialogOpen) {
|
|
40594
41577
|
issue("dialog-escape", "Pressing Escape did not close the dialog.");
|
|
40595
41578
|
await page.reload({ waitUntil: "load" }).catch(() => void 0);
|
|
@@ -40645,6 +41628,7 @@ async function newContext(browser, vp, opts) {
|
|
|
40645
41628
|
serviceWorkers: "block",
|
|
40646
41629
|
...opts.storageState ? { storageState: opts.storageState } : {}
|
|
40647
41630
|
});
|
|
41631
|
+
watchContext(browser, ctx);
|
|
40648
41632
|
await ctx.addInitScript({ content: NAME_SHIM });
|
|
40649
41633
|
await ctx.addInitScript({ content: VITALS_INIT_SCRIPT });
|
|
40650
41634
|
return ctx;
|
|
@@ -40652,6 +41636,7 @@ async function newContext(browser, vp, opts) {
|
|
|
40652
41636
|
function observe(page, origin) {
|
|
40653
41637
|
const consoleEntries = [];
|
|
40654
41638
|
const network = [];
|
|
41639
|
+
let ignored = () => false;
|
|
40655
41640
|
page.on("console", (msg) => {
|
|
40656
41641
|
const t = msg.type();
|
|
40657
41642
|
if (t !== "error" && t !== "warning") return;
|
|
@@ -40674,6 +41659,7 @@ function observe(page, origin) {
|
|
|
40674
41659
|
}
|
|
40675
41660
|
};
|
|
40676
41661
|
page.on("requestfailed", (req) => {
|
|
41662
|
+
if (ignored(req.url())) return;
|
|
40677
41663
|
const failure = req.failure()?.errorText ?? "failed";
|
|
40678
41664
|
if (/ERR_ABORTED|NS_BINDING_ABORTED|cancelled/i.test(failure)) return;
|
|
40679
41665
|
if (network.length >= 100) return;
|
|
@@ -40695,7 +41681,13 @@ function observe(page, origin) {
|
|
|
40695
41681
|
firstParty: firstParty(res.url())
|
|
40696
41682
|
});
|
|
40697
41683
|
});
|
|
40698
|
-
return {
|
|
41684
|
+
return {
|
|
41685
|
+
console: consoleEntries,
|
|
41686
|
+
network,
|
|
41687
|
+
ignore: (fn) => {
|
|
41688
|
+
ignored = fn;
|
|
41689
|
+
}
|
|
41690
|
+
};
|
|
40699
41691
|
}
|
|
40700
41692
|
async function settle(page, timeoutMs) {
|
|
40701
41693
|
await page.waitForLoadState("networkidle", { timeout: Math.min(timeoutMs, 5e3) }).catch(() => void 0);
|
|
@@ -40832,12 +41824,12 @@ async function capturePage(ctx, url, vp, opts) {
|
|
|
40832
41824
|
if (opts.screenshots !== false) {
|
|
40833
41825
|
const base2 = `${slug(new URL(url).pathname.replace(/^\/$/, "home"), 40)}-${shortHash(url).slice(0, 6)}-${vp.name}`;
|
|
40834
41826
|
const mask = [...DEFAULT_MASK, ...opts.mask ?? []].map((s) => page.locator(s));
|
|
40835
|
-
const full =
|
|
40836
|
-
const fold =
|
|
41827
|
+
const full = join5("screenshots", `${base2}.jpg`);
|
|
41828
|
+
const fold = join5("screenshots", `${base2}-fold.jpg`);
|
|
40837
41829
|
try {
|
|
40838
41830
|
const height = Math.min(obs.probe?.scroll.height ?? vp.height, 12e3);
|
|
40839
41831
|
await page.screenshot({
|
|
40840
|
-
path:
|
|
41832
|
+
path: join5(opts.runDir, full),
|
|
40841
41833
|
type: "jpeg",
|
|
40842
41834
|
quality: 80,
|
|
40843
41835
|
fullPage: true,
|
|
@@ -40849,7 +41841,7 @@ async function capturePage(ctx, url, vp, opts) {
|
|
|
40849
41841
|
});
|
|
40850
41842
|
obs.screenshot = toPosix(full);
|
|
40851
41843
|
await page.screenshot({
|
|
40852
|
-
path:
|
|
41844
|
+
path: join5(opts.runDir, fold),
|
|
40853
41845
|
type: "jpeg",
|
|
40854
41846
|
quality: 80,
|
|
40855
41847
|
animations: "disabled",
|
|
@@ -40863,6 +41855,15 @@ async function capturePage(ctx, url, vp, opts) {
|
|
|
40863
41855
|
}
|
|
40864
41856
|
}
|
|
40865
41857
|
if (opts.keyboard !== false) {
|
|
41858
|
+
const guard = await installRequestGuard(page, {
|
|
41859
|
+
origin,
|
|
41860
|
+
hostClass: "public",
|
|
41861
|
+
allowSubmit: false,
|
|
41862
|
+
allowDestructive: false,
|
|
41863
|
+
mode: "read-only"
|
|
41864
|
+
});
|
|
41865
|
+
live.ignore(guard.isBlocked);
|
|
41866
|
+
obs.blockedActions = guard.blocked;
|
|
40866
41867
|
obs.keyboard = await keyboardWalk(page).catch((e) => {
|
|
40867
41868
|
obs.errors.push(`keyboard: ${e.message.split("\n")[0]}`);
|
|
40868
41869
|
return null;
|
|
@@ -40933,59 +41934,6 @@ function sanitizeInventory(inv) {
|
|
|
40933
41934
|
}
|
|
40934
41935
|
}
|
|
40935
41936
|
|
|
40936
|
-
// src/browser/runtime.ts
|
|
40937
|
-
init_errors();
|
|
40938
|
-
import { spawnSync } from "node:child_process";
|
|
40939
|
-
import { createRequire } from "node:module";
|
|
40940
|
-
import { dirname as dirname4, join as join4 } from "node:path";
|
|
40941
|
-
var cached;
|
|
40942
|
-
async function loadPlaywright() {
|
|
40943
|
-
if (cached) return cached;
|
|
40944
|
-
try {
|
|
40945
|
-
cached = await import("playwright-core");
|
|
40946
|
-
return cached;
|
|
40947
|
-
} catch {
|
|
40948
|
-
throw new JouriloError(
|
|
40949
|
-
"BROWSER_UNAVAILABLE",
|
|
40950
|
-
"playwright-core is not installed.",
|
|
40951
|
-
"Run `npm install` in the Jourilo directory (or `npx jourilo setup`)."
|
|
40952
|
-
);
|
|
40953
|
-
}
|
|
40954
|
-
}
|
|
40955
|
-
async function launchBrowser(name = "chromium", headed = false) {
|
|
40956
|
-
const pw = await loadPlaywright();
|
|
40957
|
-
const type = name === "firefox" ? pw.firefox : name === "webkit" ? pw.webkit : pw.chromium;
|
|
40958
|
-
const opts = { headless: !headed };
|
|
40959
|
-
if (name === "chrome" || name === "msedge") opts.channel = name;
|
|
40960
|
-
try {
|
|
40961
|
-
return await type.launch(opts);
|
|
40962
|
-
} catch (err) {
|
|
40963
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
40964
|
-
if (/Executable doesn't exist|browserType\.launch: .*not found|install/i.test(msg)) {
|
|
40965
|
-
throw new JouriloError(
|
|
40966
|
-
"BROWSER_UNAVAILABLE",
|
|
40967
|
-
`The ${name} browser is not installed for Playwright.`,
|
|
40968
|
-
name === "chrome" || name === "msedge" ? `Install ${name === "chrome" ? "Google Chrome" : "Microsoft Edge"}, or use --browser chromium.` : `Run \`jourilo setup --browser ${name}\` (downloads the browser build Playwright needs), or use --browser chrome to drive an installed Google Chrome.`
|
|
40969
|
-
);
|
|
40970
|
-
}
|
|
40971
|
-
throw new JouriloError("BROWSER_UNAVAILABLE", `Could not launch ${name}: ${msg.split("\n")[0]}`);
|
|
40972
|
-
}
|
|
40973
|
-
}
|
|
40974
|
-
function installBrowser(name) {
|
|
40975
|
-
const require2 = createRequire(import.meta.url);
|
|
40976
|
-
let cli;
|
|
40977
|
-
try {
|
|
40978
|
-
cli = join4(dirname4(require2.resolve("playwright-core/package.json")), "cli.js");
|
|
40979
|
-
} catch {
|
|
40980
|
-
throw new JouriloError(
|
|
40981
|
-
"BROWSER_UNAVAILABLE",
|
|
40982
|
-
"playwright-core is not installed; cannot install browsers."
|
|
40983
|
-
);
|
|
40984
|
-
}
|
|
40985
|
-
const res = spawnSync(process.execPath, [cli, "install", name], { encoding: "utf8" });
|
|
40986
|
-
return { ok: res.status === 0, output: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() };
|
|
40987
|
-
}
|
|
40988
|
-
|
|
40989
41937
|
// src/audit/finding.ts
|
|
40990
41938
|
function pathOf(url) {
|
|
40991
41939
|
try {
|
|
@@ -41179,6 +42127,16 @@ function nielsen(n) {
|
|
|
41179
42127
|
url: "https://www.nngroup.com/articles/ten-usability-heuristics/"
|
|
41180
42128
|
};
|
|
41181
42129
|
}
|
|
42130
|
+
var MANUAL_REVIEW = [
|
|
42131
|
+
"1.1.1 Non-text content: is each text alternative accurate and useful (presence is checked, meaning is not)?",
|
|
42132
|
+
"1.2.x Time-based media: are captions, audio description and transcripts correct and complete?",
|
|
42133
|
+
"1.3.2 Meaningful sequence and 1.3.3 Sensory characteristics: does the reading order make sense; do instructions rely only on shape, position or colour?",
|
|
42134
|
+
"1.4.1 Use of colour and 1.4.11 Non-text contrast (where not measured): is colour the only cue anywhere?",
|
|
42135
|
+
"2.4.4 / 2.4.6 Link purpose, headings and labels: are they descriptive in context?",
|
|
42136
|
+
"2.5.7 Dragging movements: does every drag have a single-pointer alternative?",
|
|
42137
|
+
"3.1.2 Language of parts, 3.2.6 Consistent help, 3.3.7 Redundant entry, 3.3.8 Accessible authentication (beyond paste blocking).",
|
|
42138
|
+
"4.1.3 Status messages and the overall experience with a screen reader and with voice control."
|
|
42139
|
+
];
|
|
41182
42140
|
|
|
41183
42141
|
// src/conversion/rules.ts
|
|
41184
42142
|
var VAGUE = lexicon("vagueCta", { exact: true });
|
|
@@ -41399,97 +42357,6 @@ function formFriction(obs, form) {
|
|
|
41399
42357
|
init_errors();
|
|
41400
42358
|
init_types();
|
|
41401
42359
|
|
|
41402
|
-
// src/core/url-safety.ts
|
|
41403
|
-
init_errors();
|
|
41404
|
-
import { lookup } from "node:dns/promises";
|
|
41405
|
-
import { isIP } from "node:net";
|
|
41406
|
-
var LOCAL_SUFFIXES = [".localhost", ".test"];
|
|
41407
|
-
function isLocalHostname(host) {
|
|
41408
|
-
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
41409
|
-
return h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h) || h === "0.0.0.0" || LOCAL_SUFFIXES.some((s) => h.endsWith(s));
|
|
41410
|
-
}
|
|
41411
|
-
function isPrivateIp(ip) {
|
|
41412
|
-
const v = ip.replace(/^\[|\]$/g, "").toLowerCase();
|
|
41413
|
-
if (isIP(v) === 4) {
|
|
41414
|
-
const [a, b] = v.split(".").map(Number);
|
|
41415
|
-
return a === 10 || a === 127 || a === 0 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127;
|
|
41416
|
-
}
|
|
41417
|
-
if (isIP(v) === 6) {
|
|
41418
|
-
if (v === "::1" || v === "::") return true;
|
|
41419
|
-
if (v.startsWith("fc") || v.startsWith("fd") || v.startsWith("fe80")) return true;
|
|
41420
|
-
const mapped = v.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
41421
|
-
return mapped ? isPrivateIp(mapped[1]) : false;
|
|
41422
|
-
}
|
|
41423
|
-
return false;
|
|
41424
|
-
}
|
|
41425
|
-
function parseTargetUrl(raw) {
|
|
41426
|
-
let url;
|
|
41427
|
-
try {
|
|
41428
|
-
url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
|
|
41429
|
-
} catch {
|
|
41430
|
-
throw new JouriloError("INVALID_INPUT", `Not a valid URL: ${raw}`);
|
|
41431
|
-
}
|
|
41432
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
41433
|
-
throw new JouriloError("UNSAFE_URL", `Only http and https URLs are supported (got ${url.protocol}).`);
|
|
41434
|
-
}
|
|
41435
|
-
url.hash = "";
|
|
41436
|
-
return url;
|
|
41437
|
-
}
|
|
41438
|
-
async function checkTarget(url, opts = {}) {
|
|
41439
|
-
const host = url.hostname;
|
|
41440
|
-
if (isLocalHostname(host)) return "local";
|
|
41441
|
-
if (isPrivateIp(host)) {
|
|
41442
|
-
if (!opts.allowPrivate) {
|
|
41443
|
-
throw new JouriloError(
|
|
41444
|
-
"UNSAFE_URL",
|
|
41445
|
-
`${host} is a private network address.`,
|
|
41446
|
-
"Pass --allow-private if you are authorized to test this host."
|
|
41447
|
-
);
|
|
41448
|
-
}
|
|
41449
|
-
return "private";
|
|
41450
|
-
}
|
|
41451
|
-
if (opts.resolveDns !== false && isIP(host) === 0) {
|
|
41452
|
-
try {
|
|
41453
|
-
const addrs = await lookup(host, { all: true });
|
|
41454
|
-
if (addrs.length && addrs.every((a) => isPrivateIp(a.address)) && !opts.allowPrivate) {
|
|
41455
|
-
throw new JouriloError(
|
|
41456
|
-
"UNSAFE_URL",
|
|
41457
|
-
`${host} resolves to a private network address.`,
|
|
41458
|
-
"Pass --allow-private if you are authorized to test this host."
|
|
41459
|
-
);
|
|
41460
|
-
}
|
|
41461
|
-
if (addrs.some((a) => isPrivateIp(a.address))) return "private";
|
|
41462
|
-
} catch (err) {
|
|
41463
|
-
if (err instanceof JouriloError) throw err;
|
|
41464
|
-
}
|
|
41465
|
-
}
|
|
41466
|
-
return "public";
|
|
41467
|
-
}
|
|
41468
|
-
function isCrawlable(candidate, origin) {
|
|
41469
|
-
let u;
|
|
41470
|
-
try {
|
|
41471
|
-
u = new URL(candidate);
|
|
41472
|
-
} catch {
|
|
41473
|
-
return false;
|
|
41474
|
-
}
|
|
41475
|
-
if (u.origin !== origin) return false;
|
|
41476
|
-
if (/\/(log-?out|sign-?out|logoff|delete|destroy|remove|unsubscribe|cancel)(\/|$|\?)/i.test(u.pathname))
|
|
41477
|
-
return false;
|
|
41478
|
-
if (/\.(pdf|zip|gz|dmg|exe|msi|png|jpe?g|gif|webp|svg|mp4|mp3|woff2?|ttf|ico|xml|json|txt|csv)$/i.test(
|
|
41479
|
-
u.pathname
|
|
41480
|
-
))
|
|
41481
|
-
return false;
|
|
41482
|
-
return true;
|
|
41483
|
-
}
|
|
41484
|
-
function normalizeForCrawl(raw) {
|
|
41485
|
-
const u = new URL(raw);
|
|
41486
|
-
u.hash = "";
|
|
41487
|
-
for (const k of [...u.searchParams.keys()])
|
|
41488
|
-
if (/^utm_|^(fbclid|gclid|ref)$/i.test(k)) u.searchParams.delete(k);
|
|
41489
|
-
if (u.pathname !== "/" && u.pathname.endsWith("/")) u.pathname = u.pathname.replace(/\/+$/, "");
|
|
41490
|
-
return u.toString();
|
|
41491
|
-
}
|
|
41492
|
-
|
|
41493
42360
|
// src/discovery/robots.ts
|
|
41494
42361
|
function parseRobots(text, agent2 = "jourilo") {
|
|
41495
42362
|
const groups = [];
|
|
@@ -41569,49 +42436,50 @@ function rankUrl(url, depth) {
|
|
|
41569
42436
|
for (const [re, w] of IMPORTANT) if (re.test(path2)) score += w;
|
|
41570
42437
|
return score;
|
|
41571
42438
|
}
|
|
41572
|
-
async function fetchText(url, timeoutMs) {
|
|
41573
|
-
|
|
41574
|
-
|
|
41575
|
-
|
|
41576
|
-
|
|
41577
|
-
headers: { "user-agent": `${USER_AGENT_TOKEN} (+https://github.com/cihantopac/jourilo)` }
|
|
41578
|
-
});
|
|
41579
|
-
if (!res.ok) return null;
|
|
41580
|
-
if (new URL(res.url).origin !== new URL(url).origin) return null;
|
|
41581
|
-
const text = await res.text();
|
|
41582
|
-
return text.slice(0, 2e6);
|
|
41583
|
-
} catch {
|
|
41584
|
-
return null;
|
|
41585
|
-
}
|
|
42439
|
+
async function fetchText(url, timeoutMs, policy) {
|
|
42440
|
+
return guardedFetchText(url, policy, {
|
|
42441
|
+
timeoutMs,
|
|
42442
|
+
userAgent: `${USER_AGENT_TOKEN} (+https://github.com/cihantopac/jourilo)`
|
|
42443
|
+
}).catch(() => null);
|
|
41586
42444
|
}
|
|
41587
|
-
async function loadRobots(origin, timeoutMs = 8e3) {
|
|
41588
|
-
const text = await fetchText(`${origin}/robots.txt`, timeoutMs);
|
|
42445
|
+
async function loadRobots(origin, policy, timeoutMs = 8e3) {
|
|
42446
|
+
const text = await fetchText(`${origin}/robots.txt`, timeoutMs, policy);
|
|
41589
42447
|
return text === null ? null : parseRobots(text, USER_AGENT_TOKEN);
|
|
41590
42448
|
}
|
|
41591
|
-
async function sitemapUrls(origin, robots, timeoutMs) {
|
|
41592
|
-
const
|
|
41593
|
-
const
|
|
41594
|
-
for (const sm of candidates.slice(0, 3)) {
|
|
41595
|
-
let smUrl;
|
|
42449
|
+
async function sitemapUrls(origin, robots, timeoutMs, policy) {
|
|
42450
|
+
const local = policy.target === "local";
|
|
42451
|
+
const rebase = (raw) => {
|
|
41596
42452
|
try {
|
|
41597
|
-
|
|
42453
|
+
const u = new URL(raw, origin);
|
|
42454
|
+
if (u.origin === origin) return u.toString();
|
|
42455
|
+
return local ? new URL(`${u.pathname}${u.search}`, origin).toString() : null;
|
|
41598
42456
|
} catch {
|
|
41599
|
-
|
|
42457
|
+
return null;
|
|
41600
42458
|
}
|
|
41601
|
-
|
|
41602
|
-
|
|
42459
|
+
};
|
|
42460
|
+
const candidates = [...robots?.sitemaps ?? [], `${origin}/sitemap.xml`];
|
|
42461
|
+
const urls = [];
|
|
42462
|
+
let rebasedFrom = null;
|
|
42463
|
+
for (const sm of candidates.slice(0, 3)) {
|
|
42464
|
+
const smUrl = rebase(sm);
|
|
42465
|
+
if (!smUrl) continue;
|
|
42466
|
+
const xml = await fetchText(smUrl, timeoutMs, policy);
|
|
41603
42467
|
if (!xml) continue;
|
|
41604
42468
|
for (const m of xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)) {
|
|
41605
|
-
|
|
42469
|
+
const loc = m[1].replace(/&/g, "&");
|
|
42470
|
+
const mapped = rebase(loc);
|
|
42471
|
+
if (!mapped) continue;
|
|
42472
|
+
if (mapped !== loc) rebasedFrom ??= new URL(loc).origin;
|
|
42473
|
+
urls.push(mapped);
|
|
41606
42474
|
if (urls.length >= 500) break;
|
|
41607
42475
|
}
|
|
41608
42476
|
if (urls.length) break;
|
|
41609
42477
|
}
|
|
41610
|
-
return urls;
|
|
42478
|
+
return { urls, rebasedFrom };
|
|
41611
42479
|
}
|
|
41612
42480
|
async function crawl(ctx, start, opts) {
|
|
41613
42481
|
const origin = new URL(start).origin;
|
|
41614
|
-
const robots = opts.respectRobots ? await loadRobots(origin) : null;
|
|
42482
|
+
const robots = opts.respectRobots ? await loadRobots(origin, opts.policy) : null;
|
|
41615
42483
|
const skipped = [];
|
|
41616
42484
|
const seen = /* @__PURE__ */ new Map();
|
|
41617
42485
|
const queue = [];
|
|
@@ -41634,8 +42502,8 @@ async function crawl(ctx, start, opts) {
|
|
|
41634
42502
|
};
|
|
41635
42503
|
push(start, 0);
|
|
41636
42504
|
for (const extra of opts.include ?? []) push(new URL(extra, start).toString(), 0);
|
|
41637
|
-
const fromSitemap = await sitemapUrls(origin, robots, opts.timeoutMs);
|
|
41638
|
-
for (const u of fromSitemap) push(u, 1);
|
|
42505
|
+
const fromSitemap = await sitemapUrls(origin, robots, opts.timeoutMs, opts.policy);
|
|
42506
|
+
for (const u of fromSitemap.urls) push(u, 1);
|
|
41639
42507
|
const visited = /* @__PURE__ */ new Set();
|
|
41640
42508
|
const page = await ctx.newPage();
|
|
41641
42509
|
try {
|
|
@@ -41676,13 +42544,19 @@ async function crawl(ctx, start, opts) {
|
|
|
41676
42544
|
if (picked.length >= opts.maxPages) break;
|
|
41677
42545
|
if (!picked.includes(p)) picked.push(p);
|
|
41678
42546
|
}
|
|
41679
|
-
return {
|
|
42547
|
+
return {
|
|
42548
|
+
pages: picked,
|
|
42549
|
+
skipped,
|
|
42550
|
+
robots,
|
|
42551
|
+
sitemapUrls: fromSitemap.urls.length,
|
|
42552
|
+
sitemapRebasedFrom: fromSitemap.rebasedFrom
|
|
42553
|
+
};
|
|
41680
42554
|
}
|
|
41681
42555
|
|
|
41682
42556
|
// src/integrations/context.ts
|
|
41683
42557
|
init_fs_safety();
|
|
41684
42558
|
import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync } from "node:fs";
|
|
41685
|
-
import { join as
|
|
42559
|
+
import { join as join6, relative as relative2 } from "node:path";
|
|
41686
42560
|
var CONTEXT_SCHEMA = "jourilo.context/v1";
|
|
41687
42561
|
function readJson(file) {
|
|
41688
42562
|
try {
|
|
@@ -41716,11 +42590,11 @@ function flattenTokens(tokens) {
|
|
|
41716
42590
|
}
|
|
41717
42591
|
function readMarkilo(root, brandSlug) {
|
|
41718
42592
|
for (const dir of ["branding", "brand"]) {
|
|
41719
|
-
const manifestFile =
|
|
42593
|
+
const manifestFile = join6(root, dir, "markilo.json");
|
|
41720
42594
|
const manifest = readJson(manifestFile);
|
|
41721
42595
|
if (!manifest?.brands?.length) continue;
|
|
41722
42596
|
const brand = manifest.brands.find((b) => b.slug === brandSlug) ?? manifest.brands.find((b) => b.role === "master") ?? manifest.brands[0];
|
|
41723
|
-
const tokensFile =
|
|
42597
|
+
const tokensFile = join6(root, dir, "brands", brand.slug, "exports", "tokens", "tokens.json");
|
|
41724
42598
|
if (!existsSync4(tokensFile)) continue;
|
|
41725
42599
|
const { colors, fonts } = flattenTokens(readJson(tokensFile));
|
|
41726
42600
|
return {
|
|
@@ -41735,13 +42609,13 @@ function readMarkilo(root, brandSlug) {
|
|
|
41735
42609
|
return null;
|
|
41736
42610
|
}
|
|
41737
42611
|
function readSearvex(root) {
|
|
41738
|
-
const runs2 =
|
|
42612
|
+
const runs2 = join6(root, ".searvex", "runs");
|
|
41739
42613
|
if (!existsSync4(runs2)) return null;
|
|
41740
|
-
const dirs = readdirSync2(runs2).filter((d) => existsSync4(
|
|
41741
|
-
(a, b) => statSync(
|
|
42614
|
+
const dirs = readdirSync2(runs2).filter((d) => existsSync4(join6(runs2, d, "report.json"))).sort(
|
|
42615
|
+
(a, b) => statSync(join6(runs2, b, "report.json")).mtimeMs - statSync(join6(runs2, a, "report.json")).mtimeMs
|
|
41742
42616
|
);
|
|
41743
42617
|
if (!dirs.length) return null;
|
|
41744
|
-
const file =
|
|
42618
|
+
const file = join6(runs2, dirs[0], "report.json");
|
|
41745
42619
|
const report2 = readJson(file);
|
|
41746
42620
|
if (!report2?.findings) return null;
|
|
41747
42621
|
return {
|
|
@@ -41757,7 +42631,7 @@ function readSearvex(root) {
|
|
|
41757
42631
|
};
|
|
41758
42632
|
}
|
|
41759
42633
|
function readExplicit(root) {
|
|
41760
|
-
const file =
|
|
42634
|
+
const file = join6(root, "jourilo.context.json");
|
|
41761
42635
|
const data = readJson(file);
|
|
41762
42636
|
if (!data) return null;
|
|
41763
42637
|
if (data.schema !== CONTEXT_SCHEMA)
|
|
@@ -41946,6 +42820,12 @@ var AFTER = `([sel, beforeText]) => {
|
|
|
41946
42820
|
}`;
|
|
41947
42821
|
async function probeFormValidation(ctx, url, form, vp, policy, runDir) {
|
|
41948
42822
|
const page = await ctx.newPage();
|
|
42823
|
+
const guard = await installRequestGuard(page, {
|
|
42824
|
+
...policy,
|
|
42825
|
+
allowDestructive: false,
|
|
42826
|
+
origin: new URL(url).origin,
|
|
42827
|
+
mode: policy.allowSubmit ? "journey" : "read-only"
|
|
42828
|
+
});
|
|
41949
42829
|
const base2 = {
|
|
41950
42830
|
url: redactUrl(url),
|
|
41951
42831
|
form: form.selector,
|
|
@@ -41978,10 +42858,12 @@ async function probeFormValidation(ctx, url, form, vp, policy, runDir) {
|
|
|
41978
42858
|
);
|
|
41979
42859
|
const startUrl = page.url();
|
|
41980
42860
|
const submit = page.locator(form.selector).locator('button[type="submit"], button:not([type]), input[type="submit"]').first();
|
|
42861
|
+
const submittedAt = Date.now();
|
|
41981
42862
|
if (await submit.count()) await submit.click({ timeout: 1e4 });
|
|
41982
42863
|
else await page.locator(form.selector).locator("input").first().press("Enter");
|
|
41983
42864
|
await page.waitForLoadState("networkidle", { timeout: 3e3 }).catch(() => void 0);
|
|
41984
42865
|
await page.waitForTimeout(600);
|
|
42866
|
+
const intercepted = guard.since(submittedAt);
|
|
41985
42867
|
const shotFile = toPosix(`screenshots/form-${shortHash(url, form.selector).slice(0, 8)}-${vp.name}.jpg`);
|
|
41986
42868
|
await page.screenshot({
|
|
41987
42869
|
path: `${runDir}/${shotFile}`,
|
|
@@ -42000,6 +42882,16 @@ async function probeFormValidation(ctx, url, form, vp, policy, runDir) {
|
|
|
42000
42882
|
const evidence = [
|
|
42001
42883
|
{ type: "screenshot", file: shotFile, note: "After submitting the empty form" }
|
|
42002
42884
|
];
|
|
42885
|
+
if (after && !after.errorish.length && !after.ariaInvalid && intercepted.length) {
|
|
42886
|
+
return {
|
|
42887
|
+
result: {
|
|
42888
|
+
...base2,
|
|
42889
|
+
outcome: "simulated",
|
|
42890
|
+
detail: `No client-side validation; the empty submission (${intercepted[0].method} ${intercepted[0].url}) was intercepted, so server-side feedback was not tested.`
|
|
42891
|
+
},
|
|
42892
|
+
findings
|
|
42893
|
+
};
|
|
42894
|
+
}
|
|
42003
42895
|
if (!after || !after.errorish.length && !after.ariaInvalid) {
|
|
42004
42896
|
const accepted = navigated && after !== null;
|
|
42005
42897
|
findings.push(
|
|
@@ -42093,6 +42985,7 @@ async function probeFormValidation(ctx, url, form, vp, policy, runDir) {
|
|
|
42093
42985
|
} catch (err) {
|
|
42094
42986
|
return { result: { ...base2, outcome: "error", detail: excerpt(err.message, 200) }, findings };
|
|
42095
42987
|
} finally {
|
|
42988
|
+
await guard.detach();
|
|
42096
42989
|
await page.close().catch(() => void 0);
|
|
42097
42990
|
}
|
|
42098
42991
|
}
|
|
@@ -42304,18 +43197,20 @@ function keyboardFindings(obs) {
|
|
|
42304
43197
|
);
|
|
42305
43198
|
}
|
|
42306
43199
|
if (kb.trap) {
|
|
43200
|
+
const advised = !!kb.trap.advice && kb.trap.adviceWorks !== false;
|
|
43201
|
+
const broken = !!kb.trap.advice && kb.trap.adviceWorks === false;
|
|
42307
43202
|
out.push(
|
|
42308
43203
|
makeFinding({
|
|
42309
43204
|
ruleId: "jourilo/keyboard-trap",
|
|
42310
|
-
title: "Keyboard focus is trapped",
|
|
43205
|
+
title: advised ? "Keyboard focus is held; the page explains a way out" : "Keyboard focus is trapped",
|
|
42311
43206
|
category: "accessibility",
|
|
42312
|
-
kind: "defect",
|
|
42313
|
-
severity: "critical",
|
|
43207
|
+
kind: advised ? "needs-review" : "defect",
|
|
43208
|
+
severity: advised ? "moderate" : "critical",
|
|
42314
43209
|
confidence: "medium",
|
|
42315
43210
|
provenance: "observed",
|
|
42316
43211
|
url: obs.url,
|
|
42317
43212
|
viewport: vp,
|
|
42318
|
-
description: `Pressing Tab cycled between ${kb.trap.selectors.length} element(s) outside any modal dialog while the page has ${kb.focusableCount} focusable elements
|
|
43213
|
+
description: `Pressing Tab cycled between ${kb.trap.selectors.length} element(s) outside any modal dialog while the page has ${kb.focusableCount} focusable elements.${advised ? ` The page explains how to leave ("${kb.trap.advice}")${kb.trap.adviceWorks ? ", and that key let focus out" : ""}; check that the instruction is announced before focus is held.` : ""}${broken ? ` The page says "${kb.trap.advice}", but pressing it did not let focus out.` : ""}`,
|
|
42319
43214
|
impact: "Keyboard users cannot reach the rest of the page and may have to reload it.",
|
|
42320
43215
|
recommendation: 'Remove custom keydown handlers that intercept Tab outside dialogs; if this is a dialog, mark it aria-modal="true" and provide Escape to close.',
|
|
42321
43216
|
standards: [wcag("2.1.2")],
|
|
@@ -42510,6 +43405,22 @@ var WIDGET_RULES = {
|
|
|
42510
43405
|
}
|
|
42511
43406
|
]
|
|
42512
43407
|
},
|
|
43408
|
+
"composite-unreachable": {
|
|
43409
|
+
ruleId: "jourilo/composite-keyboard",
|
|
43410
|
+
title: "Items of a tab list, radio group, list box or menu bar cannot be reached with the keyboard",
|
|
43411
|
+
severity: "serious",
|
|
43412
|
+
impact: "Keyboard and screen-reader users can reach only the current item; the other tabs, options or choices are out of reach.",
|
|
43413
|
+
recommendation: "Implement the arrow-key pattern for this widget (move focus or aria-activedescendant to the next item with the arrow keys), or put every item in the Tab order.",
|
|
43414
|
+
standards: [
|
|
43415
|
+
wcag("2.1.1"),
|
|
43416
|
+
{
|
|
43417
|
+
standard: "WAI-ARIA APG",
|
|
43418
|
+
id: "keyboard-interface",
|
|
43419
|
+
title: "Developing a Keyboard Interface",
|
|
43420
|
+
url: "https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/"
|
|
43421
|
+
}
|
|
43422
|
+
]
|
|
43423
|
+
},
|
|
42513
43424
|
"dialog-escape": {
|
|
42514
43425
|
ruleId: "jourilo/dialog-escape",
|
|
42515
43426
|
title: "Escape does not close the dialog",
|
|
@@ -42600,8 +43511,7 @@ function widgetFindings(obs) {
|
|
|
42600
43511
|
occurrences: issues.length,
|
|
42601
43512
|
reproduction: [
|
|
42602
43513
|
`Open ${obs.url} at the ${obs.viewport.name} viewport.`,
|
|
42603
|
-
`Focus "${issues[0].name}" with Tab and press Enter.`,
|
|
42604
|
-
issues[0].detail
|
|
43514
|
+
...issues[0].steps ?? [`Focus "${issues[0].name}" with Tab and press Enter.`, issues[0].detail]
|
|
42605
43515
|
]
|
|
42606
43516
|
});
|
|
42607
43517
|
});
|
|
@@ -43029,9 +43939,13 @@ async function runAudit(ws, opts) {
|
|
|
43029
43939
|
const runDir = ws.createRunDir(id);
|
|
43030
43940
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
43031
43941
|
const limitations = [];
|
|
43032
|
-
let
|
|
43942
|
+
let session;
|
|
43033
43943
|
try {
|
|
43034
|
-
|
|
43944
|
+
session = await openGuardedBrowser(opts.browser, opts.headed, {
|
|
43945
|
+
target: hostClass,
|
|
43946
|
+
allowPrivate: opts.allowPrivate
|
|
43947
|
+
});
|
|
43948
|
+
const browser = session.browser;
|
|
43035
43949
|
const browserVersion = browser.version();
|
|
43036
43950
|
let urls;
|
|
43037
43951
|
if (opts.urls?.length) {
|
|
@@ -43056,6 +43970,7 @@ async function runAudit(ws, opts) {
|
|
|
43056
43970
|
maxDepth: opts.maxDepth,
|
|
43057
43971
|
respectRobots: opts.respectRobots,
|
|
43058
43972
|
timeoutMs: opts.timeoutMs,
|
|
43973
|
+
policy: session.guard.policy,
|
|
43059
43974
|
...opts.include ? { include: opts.include } : {}
|
|
43060
43975
|
});
|
|
43061
43976
|
await ctx.close();
|
|
@@ -43063,6 +43978,10 @@ async function runAudit(ws, opts) {
|
|
|
43063
43978
|
const robotsSkips = found.skipped.filter((s) => s.reason === "robots.txt").length;
|
|
43064
43979
|
if (robotsSkips)
|
|
43065
43980
|
limitations.push(`${robotsSkips} URL(s) were skipped because robots.txt disallows them.`);
|
|
43981
|
+
if (found.sitemapRebasedFrom)
|
|
43982
|
+
limitations.push(
|
|
43983
|
+
`The sitemap lists ${found.sitemapRebasedFrom} URLs; their paths were audited on the local server instead.`
|
|
43984
|
+
);
|
|
43066
43985
|
if (!found.robots && opts.respectRobots && hostClass === "public")
|
|
43067
43986
|
limitations.push("No robots.txt was found; crawling stayed on the target origin.");
|
|
43068
43987
|
if (!urls.length) urls = [{ url: target.toString() }];
|
|
@@ -43095,6 +44014,16 @@ async function runAudit(ws, opts) {
|
|
|
43095
44014
|
await ctx.close().catch(() => void 0);
|
|
43096
44015
|
}
|
|
43097
44016
|
}
|
|
44017
|
+
if (hostClass === "local" && !opts.urls?.length) {
|
|
44018
|
+
const startPath = target.pathname;
|
|
44019
|
+
const missing = observations.filter((o) => o.status === 404 && new URL(o.url).pathname === startPath);
|
|
44020
|
+
if (missing.length && observations.some((o) => (o.status ?? 200) < 400)) {
|
|
44021
|
+
for (const m of missing) observations.splice(observations.indexOf(m), 1);
|
|
44022
|
+
limitations.push(
|
|
44023
|
+
`${startPath} returned 404 on this local server (no start page in the build); it was not audited. The other pages were found through the sitemap and links.`
|
|
44024
|
+
);
|
|
44025
|
+
}
|
|
44026
|
+
}
|
|
43098
44027
|
const runs2 = Math.max(1, Math.min(9, Math.round(opts.runs ?? 1)));
|
|
43099
44028
|
if (runs2 > 1) {
|
|
43100
44029
|
for (const obs of observations) {
|
|
@@ -43117,6 +44046,7 @@ async function runAudit(ws, opts) {
|
|
|
43117
44046
|
}
|
|
43118
44047
|
}
|
|
43119
44048
|
const formFindings = [];
|
|
44049
|
+
const simulated = [];
|
|
43120
44050
|
const formsAllowed = hostClass === "local" || opts.allowSubmit === true;
|
|
43121
44051
|
if (opts.forms !== false && !opts.inventoryOnly && formsAllowed) {
|
|
43122
44052
|
const vp = opts.viewports.find((v) => !v.hasTouch) ?? opts.viewports[0];
|
|
@@ -43142,11 +44072,16 @@ async function runAudit(ws, opts) {
|
|
|
43142
44072
|
formFindings.push(...r.findings);
|
|
43143
44073
|
if (r.result.outcome === "blocked")
|
|
43144
44074
|
limitations.push(`Form ${form.selector} on ${o.url} was not submitted: ${r.result.detail}`);
|
|
44075
|
+
if (r.result.outcome === "simulated") simulated.push(`${form.selector} on ${o.url}`);
|
|
43145
44076
|
}
|
|
43146
44077
|
}
|
|
43147
44078
|
} finally {
|
|
43148
44079
|
await ctx.close().catch(() => void 0);
|
|
43149
44080
|
}
|
|
44081
|
+
if (simulated.length)
|
|
44082
|
+
limitations.push(
|
|
44083
|
+
`Form validation was simulated: empty submissions were intercepted before reaching the server, so nothing was written. ${simulated.length} form(s) rely on the server for validation feedback (${simulated.slice(0, 3).join("; ")}${simulated.length > 3 ? "; \u2026" : ""}); that feedback was not tested. Rerun with --allow-submit on a disposable environment to test it.`
|
|
44084
|
+
);
|
|
43150
44085
|
} else if (opts.forms !== false && observations.some((o) => o.probe?.inventory.forms.length)) {
|
|
43151
44086
|
limitations.push(
|
|
43152
44087
|
"Form validation was not tested: submitting forms on a public host requires --allow-submit (authorized test environments only)."
|
|
@@ -43188,6 +44123,13 @@ async function runAudit(ws, opts) {
|
|
|
43188
44123
|
"Throttling simulated a mid-range mobile network and 4\xD7 CPU slowdown (Chromium only)."
|
|
43189
44124
|
);
|
|
43190
44125
|
else limitations.push("Measured without network/CPU throttling: real mobile devices are usually slower.");
|
|
44126
|
+
const network = session.report();
|
|
44127
|
+
limitations.push(...networkLimitations(network));
|
|
44128
|
+
const stopped = observations.flatMap((o) => o.blockedActions ?? []);
|
|
44129
|
+
if (stopped.length)
|
|
44130
|
+
limitations.push(
|
|
44131
|
+
`While keys were pressed and widgets operated, ${stopped.length} request(s) that would have changed data or left the site were stopped (e.g. ${stopped[0].method} ${stopped[0].url}); audits only observe.`
|
|
44132
|
+
);
|
|
43191
44133
|
const pages = observations.map((o) => ({
|
|
43192
44134
|
url: o.url,
|
|
43193
44135
|
finalUrl: o.finalUrl,
|
|
@@ -43228,12 +44170,13 @@ async function runAudit(ws, opts) {
|
|
|
43228
44170
|
findings,
|
|
43229
44171
|
summary: summarize(findings, pages.length, 0),
|
|
43230
44172
|
limitations: [...new Set(limitations)],
|
|
43231
|
-
...opts.context && (opts.context.brand || opts.context.search) ? { context: opts.context } : {}
|
|
44173
|
+
...opts.context && (opts.context.brand || opts.context.search) ? { context: opts.context } : {},
|
|
44174
|
+
...network.blocked.length || network.escaped.length ? { network } : {}
|
|
43232
44175
|
};
|
|
43233
44176
|
ws.saveRun(run);
|
|
43234
44177
|
return { run, runDir, observations };
|
|
43235
44178
|
} finally {
|
|
43236
|
-
await
|
|
44179
|
+
await session?.close();
|
|
43237
44180
|
}
|
|
43238
44181
|
}
|
|
43239
44182
|
function summarize(findings, pages, journeys2) {
|
|
@@ -43323,7 +44266,7 @@ init_errors();
|
|
|
43323
44266
|
init_fs_safety();
|
|
43324
44267
|
import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as statSync2 } from "node:fs";
|
|
43325
44268
|
import { createServer } from "node:http";
|
|
43326
|
-
import { extname, join as
|
|
44269
|
+
import { extname, join as join7, resolve as resolve4 } from "node:path";
|
|
43327
44270
|
var TYPES = {
|
|
43328
44271
|
".html": "text/html; charset=utf-8",
|
|
43329
44272
|
".css": "text/css; charset=utf-8",
|
|
@@ -43356,24 +44299,34 @@ function stablePort(root) {
|
|
|
43356
44299
|
async function serveStatic(root, port = 0, opts = {}) {
|
|
43357
44300
|
if (!existsSync5(root) || !statSync2(root).isDirectory())
|
|
43358
44301
|
throw new JouriloError("NOT_FOUND", `Not a directory: ${root}`);
|
|
43359
|
-
const cfgFile =
|
|
44302
|
+
const cfgFile = join7(root, "jourilo-fixture.json");
|
|
43360
44303
|
const cfg = existsSync5(cfgFile) ? JSON.parse(readFileSync4(cfgFile, "utf8")) : {};
|
|
43361
44304
|
const spa = opts.spa ?? cfg.spa ?? looksLikeSpa(root);
|
|
43362
44305
|
const server = createServer((req, res) => {
|
|
43363
44306
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
43364
|
-
let pathname2
|
|
44307
|
+
let pathname2;
|
|
44308
|
+
try {
|
|
44309
|
+
pathname2 = decodeURIComponent(url.pathname);
|
|
44310
|
+
} catch {
|
|
44311
|
+
res.writeHead(400).end("Bad request");
|
|
44312
|
+
return;
|
|
44313
|
+
}
|
|
44314
|
+
if (pathname2.split("/").some((seg) => seg.startsWith(".") && seg !== ".well-known")) {
|
|
44315
|
+
res.writeHead(404, { "content-type": "text/plain" }).end("Not found");
|
|
44316
|
+
return;
|
|
44317
|
+
}
|
|
43365
44318
|
const delay = cfg.delays?.[pathname2] ?? 0;
|
|
43366
44319
|
const send = () => {
|
|
43367
44320
|
try {
|
|
43368
44321
|
let file = resolveInside(root, `.${pathname2}`);
|
|
43369
|
-
if (existsSync5(file) && statSync2(file).isDirectory()) file =
|
|
44322
|
+
if (existsSync5(file) && statSync2(file).isDirectory()) file = join7(file, "index.html");
|
|
43370
44323
|
if (!existsSync5(file) && !extname(pathname2)) {
|
|
43371
44324
|
const html = resolveInside(root, `.${pathname2}.html`);
|
|
43372
44325
|
if (existsSync5(html)) file = html;
|
|
43373
44326
|
}
|
|
43374
|
-
if (!existsSync5(file) && spa && !extname(pathname2)) file =
|
|
44327
|
+
if (!existsSync5(file) && spa && !extname(pathname2)) file = join7(root, "index.html");
|
|
43375
44328
|
if (!existsSync5(file)) {
|
|
43376
|
-
const notFound =
|
|
44329
|
+
const notFound = join7(root, "404.html");
|
|
43377
44330
|
res.writeHead(404, { "content-type": TYPES[".html"] });
|
|
43378
44331
|
res.end(existsSync5(notFound) ? readFileSync4(notFound) : "Not found");
|
|
43379
44332
|
return;
|
|
@@ -43419,7 +44372,7 @@ async function serveStatic(root, port = 0, opts = {}) {
|
|
|
43419
44372
|
// src/repair/locate.ts
|
|
43420
44373
|
init_fs_safety();
|
|
43421
44374
|
import { readdirSync as readdirSync4, readFileSync as readFileSync5, statSync as statSync3 } from "node:fs";
|
|
43422
|
-
import { extname as extname2, join as
|
|
44375
|
+
import { extname as extname2, join as join8, relative as relative3 } from "node:path";
|
|
43423
44376
|
var SOURCE_EXT = /* @__PURE__ */ new Set([
|
|
43424
44377
|
".html",
|
|
43425
44378
|
".htm",
|
|
@@ -43478,7 +44431,7 @@ function indexRepository(root, maxFiles = 6e3) {
|
|
|
43478
44431
|
for (const name of entries) {
|
|
43479
44432
|
if (out.length >= maxFiles) return;
|
|
43480
44433
|
if (name.startsWith(".") && name !== ".vitepress") continue;
|
|
43481
|
-
const full =
|
|
44434
|
+
const full = join8(dir, name);
|
|
43482
44435
|
let st;
|
|
43483
44436
|
try {
|
|
43484
44437
|
st = statSync3(full);
|
|
@@ -43761,7 +44714,26 @@ function locateFindings(run, repoRoot) {
|
|
|
43761
44714
|
|
|
43762
44715
|
// src/report/index.ts
|
|
43763
44716
|
import { writeFileSync as writeFileSync4 } from "node:fs";
|
|
43764
|
-
import { join as
|
|
44717
|
+
import { join as join9 } from "node:path";
|
|
44718
|
+
|
|
44719
|
+
// src/report/brand.ts
|
|
44720
|
+
var WORDMARK_VIEWBOX = "-18 -74 320 107";
|
|
44721
|
+
var WORDMARK_PATHS = [
|
|
44722
|
+
"m-15.5-64.4 3.2-3.2h55.5v2.6l-8 2.65L35.3-2C35.3 18 25 30.5 10 30.5c-6.5 0-12-3-15-9.5l-6.5-8L-5 5l6.5 8-3 3.7c2 4.8 6 7.8 11.5 7.8 9 0 14.3-6.5 14.3-18.5v-65.5q-.7-1.3-2.8-1.7h-33.8Z",
|
|
44723
|
+
"M70.15-3.85q3.65 0 6.4-1.75t4.3-5.45 1.55-9.6q0-6.5-1.5-10.87-1.5-4.38-4.25-6.6-2.75-2.23-6.7-2.23-3.6 0-6.35 1.73-2.75 1.72-4.3 5.44-1.55 3.73-1.55 9.63 0 6.4 1.48 10.8 1.47 4.4 4.27 6.65t6.65 2.25M69.8 1.1q-6.85 0-11.97-2.87-5.13-2.88-7.93-8-2.8-5.13-2.8-11.88 0-6.95 2.98-12.3 2.97-5.35 8.2-8.37 5.22-3.03 12.07-3.03 6.9 0 11.98 2.88 5.07 2.87 7.9 7.97 2.82 5.1 2.82 11.9 0 6.95-2.97 12.3-2.98 5.35-8.2 8.38Q76.65 1.1 69.8 1.1",
|
|
44724
|
+
"M115.1-14.95q0 2.8.8 4.45t2.73 2.4q1.92.75 5.22.75 3.1 0 6.1-1.05 2.1-.75 3.5-1.8v-28.25l-6.65-3.05v-2.55l17.2-.9-.65 6.3V-8.3q.5.45 1.3 1t1.78 1.05q.97.5 1.95.9.97.4 1.82.6v1.8L136.2.9h-1.35l-1.1-7.45q-1.55 1.5-2.9 2.6Q128.45-2 126.4-.9T122.3.65t-4.45.45q-6.45 0-9.52-3.88-3.08-3.87-3.08-11.57v-23.9l-7.2-3.2v-2.6L115.7-45l-.6 6.25Z",
|
|
44725
|
+
"m186-44 6.5 8-6.5 8-6.5-8q-3.5.6-7.5 2v29.1l7.75 2.5V0h-24.9v-2.4l7.3-2.4v-29.25q-.7-.75-1.77-1.42-1.08-.68-2.48-1.38t-3.15-1.4v-1.65l14.65-5.45h1.4l1 8.15Q178-43.5 186-44",
|
|
44726
|
+
"m207.8-72.4 6.5 7.5-6.5 7.5-6.5-7.5Zm5.85 27.05-.45 9.45v31.1l7.3 2.4V0h-24.45v-2.4l7.25-2.4v-29.95q-.55-.65-1.75-1.43-1.2-.77-2.67-1.54-1.48-.78-2.98-1.33V-41l16.05-4.35Z",
|
|
44727
|
+
"m241.2-4.8 7.3 2.4V0h-24.45v-2.4l7.25-2.4v-55.5q-.7-.8-1.85-1.6t-2.75-1.65-3.55-1.65v-1.9l17.25-4.5h1.5l-.7 9.25Z",
|
|
44728
|
+
"M277-3.85q3.65 0 6.4-1.75t4.3-5.45 1.55-9.6q0-6.5-1.5-10.87-1.5-4.38-4.25-6.6-2.75-2.23-6.7-2.23-3.6 0-6.35 1.73-2.75 1.72-4.3 5.44-1.55 3.73-1.55 9.63 0 6.4 1.48 10.8 1.47 4.4 4.27 6.65T277-3.85m-.35 4.95q-6.85 0-11.97-2.87-5.13-2.88-7.93-8-2.8-5.13-2.8-11.88 0-6.95 2.98-12.3 2.97-5.35 8.2-8.37 5.22-3.03 12.07-3.03 6.9 0 11.98 2.88 5.07 2.87 7.9 7.97 2.82 5.1 2.82 11.9 0 6.95-2.97 12.3-2.98 5.35-8.2 8.38-5.23 3.02-12.08 3.02"
|
|
44729
|
+
];
|
|
44730
|
+
var SYMBOL_PATH = "m150 122 42-42h670v70q-75 10-120 50v400c0 220-130 345-310 345-90 0-150-30-175-75l-85-100 85-100 85 100-18 21c23 39 58 61 113 61 115 0 175-82 175-252V205q-12-35-52-41H192Z";
|
|
44731
|
+
function wordmarkSvg(height) {
|
|
44732
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${WORDMARK_VIEWBOX}" height="${height}" role="img" aria-label="Jourilo" fill="currentColor">${WORDMARK_PATHS.map((d) => `<path d="${d}"/>`).join("")}</svg>`;
|
|
44733
|
+
}
|
|
44734
|
+
var FAVICON_URL = `data:image/svg+xml,${encodeURIComponent(
|
|
44735
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><style>path{fill:#1c1a17}@media (prefers-color-scheme:dark){path{fill:#f4efe4}}</style><path d="${SYMBOL_PATH}"/></svg>`
|
|
44736
|
+
)}`;
|
|
43765
44737
|
|
|
43766
44738
|
// src/report/html.ts
|
|
43767
44739
|
function esc(s) {
|
|
@@ -43881,7 +44853,9 @@ function journeysHtml(journeys2) {
|
|
|
43881
44853
|
if (!journeys2.length)
|
|
43882
44854
|
return '<p class="muted">No journeys were run. Use <code>jourilo journeys suggest</code> and <code>jourilo journeys run</code>.</p>';
|
|
43883
44855
|
return journeys2.map(
|
|
43884
|
-
(j) => `<section class="journey ${j.status}"><h3>${esc(j.name)} <span class="pill st-${j.status}">${j.status}</span
|
|
44856
|
+
(j) => `<section class="journey ${j.status}"><h3>${esc(j.name)} <span class="pill st-${j.status}">${j.status}</span>${j.stability ? ` <span class="pill ${j.stability === "flaky" ? "st-failed" : "st-passed"}">${j.stability}</span>` : ""} <span class="muted">${esc(j.viewport)} \xB7 ${(j.durationMs / 1e3).toFixed(1)} s</span></h3>
|
|
44857
|
+
${j.attempts ? `<p class="muted">${j.attempts.length} attempts: ${j.attempts.map((a) => esc(a.status)).join(", ")}${j.stability === "flaky" ? " \u2014 the steps below are from a failing attempt." : ""}</p>` : ""}
|
|
44858
|
+
${j.blockedRequests?.length ? `<details><summary>${j.blockedRequests.length} request(s) stopped by the safety policy</summary><ul>${j.blockedRequests.map((b) => `<li><code>${esc(b.method)} ${esc(b.url)}</code> \u2014 ${esc(b.reason)}</li>`).join("")}</ul></details>` : ""}
|
|
43885
44859
|
<ol class="steps">${j.steps.map(
|
|
43886
44860
|
(s) => `<li class="${s.status}"><div>${esc(s.description)} <span class="pill st-${s.status}">${s.status}</span> <span class="muted">${s.durationMs} ms</span>${s.error ? `<pre>${esc(s.error)}</pre>` : ""}</div>${s.screenshot ? `<a href="${safeHref(s.screenshot)}" target="_blank" rel="noopener"><img class="stepshot" loading="lazy" alt="" src="${safeHref(s.screenshot)}"></a>` : ""}</li>`
|
|
43887
44861
|
).join("")}</ol>
|
|
@@ -43889,11 +44863,18 @@ function journeysHtml(journeys2) {
|
|
|
43889
44863
|
${j.trace ? `<p>Trace: <code>${esc(j.trace)}</code> (open with <code>npx playwright show-trace</code>)</p>` : ""}</section>`
|
|
43890
44864
|
).join("");
|
|
43891
44865
|
}
|
|
44866
|
+
function networkHtml(run) {
|
|
44867
|
+
const n = run.network;
|
|
44868
|
+
if (!n || !n.blocked.length && !n.escaped.length) return "";
|
|
44869
|
+
return `<h2>Network safety</h2>
|
|
44870
|
+
${n.escaped.length ? `<p class="poor"><strong>Security event:</strong> ${n.escaped.length} redirect(s) reached this machine outside the network guard. Rerun untrusted sites in Chromium or Firefox.</p><ul>${n.escaped.map((e) => `<li><code>${esc(e.url)}</code> \u2014 ${esc(e.reason)}</li>`).join("")}</ul>` : ""}
|
|
44871
|
+
${n.blocked.length ? `<p class="muted">Connections the page tried to open and the guard refused (content depending on them did not load):</p><table><thead><tr><th>Host</th><th>Port</th><th>Reason</th></tr></thead><tbody>${n.blocked.map((b) => `<tr><td><code>${esc(b.host)}</code></td><td>${b.port}</td><td>${esc(b.reason)}</td></tr>`).join("")}</tbody></table>` : ""}`;
|
|
44872
|
+
}
|
|
43892
44873
|
function compareHtml(cmp) {
|
|
43893
44874
|
const row = (label, n, cls) => `<div class="card ${cls}"><div class="num">${n}</div><div>${label}</div></div>`;
|
|
43894
44875
|
return `<div class="cards">${row("Fixed", cmp.fixed.length, "good")}${row("New", cmp.introduced.length, "poor")}${row("Still present", cmp.persisting.length, "")}${row("Changed severity", cmp.changed.length, "")}</div>
|
|
43895
44876
|
<p class="muted">Baseline run <code>${esc(cmp.base.id)}</code> (${esc(cmp.base.startedAt)}) vs <code>${esc(cmp.head.id)}</code> (${esc(cmp.head.startedAt)}).</p>
|
|
43896
|
-
${cmp.metrics.length ? `<table><thead><tr><th>Page</th><th>Viewport</th><th>Metric</th><th>Before</th><th>After</th><th>\u0394</th></tr></thead><tbody>${cmp.metrics.map((m) => `<tr><td>${esc(pathname(m.url))}</td><td>${esc(m.viewport)}</td><td>${esc(m.name)}</td><td>${m.before}</td><td>${m.after}</td><td class="${m.
|
|
44877
|
+
${cmp.metrics.length ? `<table><thead><tr><th>Page</th><th>Viewport</th><th>Metric</th><th>Before</th><th>After</th><th>\u0394</th><th>Verdict</th></tr></thead><tbody>${cmp.metrics.map((m) => `<tr><td>${esc(pathname(m.url))}</td><td>${esc(m.viewport)}</td><td>${esc(m.name)}</td><td>${m.before}</td><td>${m.after}</td><td class="${m.verdict === "improved" ? "good" : m.verdict === "regressed" ? "poor" : ""}">${m.delta > 0 ? "+" : ""}${m.delta}</td><td>${esc(m.verdict)}</td></tr>`).join("")}</tbody></table><p class="muted">Lab metrics. "Improved"/"regressed" need repeated visits whose ranges do not overlap (<code>--runs 5</code>); "unconfirmed" is a large change seen in single visits; "noise" is within measurement variation.</p>` : ""}`;
|
|
43897
44878
|
}
|
|
43898
44879
|
function contextHtml(run) {
|
|
43899
44880
|
const c = run.context;
|
|
@@ -43983,9 +44964,10 @@ function renderHtml(run, cmp, history = []) {
|
|
|
43983
44964
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
43984
44965
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
|
43985
44966
|
<title>Jourilo report \xB7 ${esc(run.target)}</title>
|
|
44967
|
+
<link rel="icon" href="${FAVICON_URL}">
|
|
43986
44968
|
<style>${CSS2}</style></head>
|
|
43987
44969
|
<body>
|
|
43988
|
-
<header class="top"><div class="brand"
|
|
44970
|
+
<header class="top"><div class="brand">${wordmarkSvg(34)}<span class="muted">Experience Observatory</span></div>
|
|
43989
44971
|
<div class="meta"><b>${esc(run.target)}</b><br><span class="muted">${esc(run.mode)} mode \xB7 ${esc(new Date(run.startedAt).toUTCString())} \xB7 ${esc(run.browser.name)} ${esc(run.browser.version)} \xB7 ${run.viewports.map((v) => `${esc(v.name)} ${v.width}\xD7${v.height}`).join(", ")} \xB7 Jourilo ${esc(run.tool.version)}</span></div></header>
|
|
43990
44972
|
<nav class="tabs" role="tablist">
|
|
43991
44973
|
<button role="tab" aria-selected="true" data-tab="overview">Overview</button>
|
|
@@ -44006,6 +44988,8 @@ ${roadmapHtml(findings)}
|
|
|
44006
44988
|
${historyHtml(run, history)}
|
|
44007
44989
|
${contextHtml(run)}
|
|
44008
44990
|
<h2>Limitations of this run</h2><ul class="limits">${run.limitations.map((l) => `<li>${esc(l)}</li>`).join("")}</ul>
|
|
44991
|
+
${networkHtml(run)}
|
|
44992
|
+
<h2>Still needs a person</h2><p class="muted">Automated checks cannot judge these WCAG 2.2 criteria; no run, including this one, shows conformance.</p><ul class="limits">${MANUAL_REVIEW.map((m) => `<li>${esc(m)}</li>`).join("")}</ul>
|
|
44009
44993
|
</section>
|
|
44010
44994
|
<section id="findings" class="panel" hidden>
|
|
44011
44995
|
<div class="filters">
|
|
@@ -44026,12 +45010,12 @@ ${cmp ? `<section id="compare" class="panel" hidden>${compareHtml(cmp)}</section
|
|
|
44026
45010
|
</body></html>`;
|
|
44027
45011
|
}
|
|
44028
45012
|
var CSS2 = `
|
|
44029
|
-
:root{--bg:#
|
|
44030
|
-
@media (prefers-color-scheme:dark){:root{--bg:#
|
|
45013
|
+
:root{--bg:#f4efe4;--fg:#1c1a17;--muted:#65635f;--card:#fffdf8;--line:#e2d9c7;--accent:#875802;--crit:#b91c1c;--ser:#c2410c;--mod:#955d05;--min:#4b5563;--good:#14713a;--poor:#b91c1c;--ni:#955d05}
|
|
45014
|
+
@media (prefers-color-scheme:dark){:root{--bg:#100d08;--fg:#f4efe4;--muted:#b9b7b4;--card:#1c1a17;--line:#393835;--accent:#d9af79;--crit:#f87171;--ser:#fb923c;--mod:#facc15;--min:#cbd5e1;--good:#4ade80;--poor:#f87171;--ni:#facc15}}
|
|
44031
45015
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}
|
|
44032
45016
|
a{color:var(--accent)}code,pre{font:12.5px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}pre{white-space:pre-wrap;word-break:break-word;background:var(--bg);border:1px solid var(--line);padding:6px 8px;border-radius:6px;margin:4px 0}
|
|
44033
45017
|
.top{display:flex;gap:24px;align-items:center;justify-content:space-between;flex-wrap:wrap;padding:16px 24px;border-bottom:1px solid var(--line);background:var(--card)}
|
|
44034
|
-
.brand{
|
|
45018
|
+
.brand{display:flex;align-items:center;gap:20px;font-size:15px}.brand svg{display:block;flex:none}.muted{color:var(--muted)}.meta{text-align:right;font-size:13px;overflow-wrap:anywhere}
|
|
44035
45019
|
.tabs{display:flex;gap:4px;padding:0 24px;background:var(--card);border-bottom:1px solid var(--line);overflow-x:auto}
|
|
44036
45020
|
.tabs button{font:inherit;background:none;border:0;border-bottom:3px solid transparent;padding:12px 12px;color:var(--muted);cursor:pointer;white-space:nowrap}
|
|
44037
45021
|
.tabs button[aria-selected=true]{color:var(--fg);border-color:var(--accent);font-weight:600}.tabs button:focus-visible{outline:2px solid var(--accent)}
|
|
@@ -44111,6 +45095,18 @@ function renderMarkdown(run, cmp, limit = 25) {
|
|
|
44111
45095
|
lines.push(`- \u{1F195} ${f.severity}: ${md(f.title)} \u2014 \`${path(f.url)}\``);
|
|
44112
45096
|
for (const f of cmp.fixed.slice(0, 10)) lines.push(`- \u2705 fixed: ${md(f.title)} \u2014 \`${path(f.url)}\``);
|
|
44113
45097
|
lines.push("");
|
|
45098
|
+
if (cmp.metrics.length) {
|
|
45099
|
+
lines.push(
|
|
45100
|
+
"| Page | Viewport | Metric | Before | After | Verdict |",
|
|
45101
|
+
"| ---- | -------- | ------ | ------ | ----- | ------- |",
|
|
45102
|
+
...cmp.metrics.map(
|
|
45103
|
+
(m) => `| \`${path(m.url)}\` | ${m.viewport} | ${m.name} | ${m.before} | ${m.after} | ${m.verdict} |`
|
|
45104
|
+
),
|
|
45105
|
+
"",
|
|
45106
|
+
"Lab metrics. Improved/regressed requires repeated visits whose ranges do not overlap (`--runs 5`).",
|
|
45107
|
+
""
|
|
45108
|
+
);
|
|
45109
|
+
}
|
|
44114
45110
|
}
|
|
44115
45111
|
const sorted = [...run.findings].sort(
|
|
44116
45112
|
(a, b) => severityRank(a.severity) - severityRank(b.severity) || b.occurrences - a.occurrences
|
|
@@ -44151,6 +45147,22 @@ function renderMarkdown(run, cmp, limit = 25) {
|
|
|
44151
45147
|
lines.push("");
|
|
44152
45148
|
}
|
|
44153
45149
|
lines.push("## Limitations", "", ...run.limitations.map((l) => `- ${md(l)}`), "");
|
|
45150
|
+
if (run.network?.blocked.length || run.network?.escaped.length)
|
|
45151
|
+
lines.push(
|
|
45152
|
+
"## Network safety",
|
|
45153
|
+
"",
|
|
45154
|
+
...run.network.escaped.map((e) => `- \u26A0\uFE0F security event: \`${md(e.url)}\` \u2014 ${md(e.reason)}`) ?? [],
|
|
45155
|
+
...run.network.blocked.map((b) => `- refused \`${md(b.host)}:${b.port}\` \u2014 ${md(b.reason)}`),
|
|
45156
|
+
""
|
|
45157
|
+
);
|
|
45158
|
+
lines.push(
|
|
45159
|
+
"## Still needs a person",
|
|
45160
|
+
"",
|
|
45161
|
+
"Automated checks cannot judge these WCAG 2.2 criteria; no run shows conformance.",
|
|
45162
|
+
"",
|
|
45163
|
+
...MANUAL_REVIEW.map((m) => `- ${md(m)}`),
|
|
45164
|
+
""
|
|
45165
|
+
);
|
|
44154
45166
|
return lines.join("\n");
|
|
44155
45167
|
}
|
|
44156
45168
|
function renderSarif(run) {
|
|
@@ -44203,13 +45215,13 @@ function renderSarif(run) {
|
|
|
44203
45215
|
|
|
44204
45216
|
// src/report/index.ts
|
|
44205
45217
|
function writeReports(dir, run, cmp, opts = {}) {
|
|
44206
|
-
const html =
|
|
44207
|
-
const markdown =
|
|
45218
|
+
const html = join9(dir, "report.html");
|
|
45219
|
+
const markdown = join9(dir, "report.md");
|
|
44208
45220
|
writeFileSync4(html, renderHtml(run, cmp, opts.history));
|
|
44209
45221
|
writeFileSync4(markdown, renderMarkdown(run, cmp));
|
|
44210
45222
|
const out = { html, markdown };
|
|
44211
45223
|
if (opts.sarif) {
|
|
44212
|
-
out.sarif =
|
|
45224
|
+
out.sarif = join9(dir, "report.sarif");
|
|
44213
45225
|
writeFileSync4(out.sarif, `${JSON.stringify(renderSarif(run), null, 2)}
|
|
44214
45226
|
`);
|
|
44215
45227
|
}
|
|
@@ -44367,8 +45379,11 @@ var audit = {
|
|
|
44367
45379
|
--brand SLUG Markilo brand to check design consistency against (default: master brand)
|
|
44368
45380
|
--ignore-robots Ignore robots.txt (only for sites you own or are authorized to test)
|
|
44369
45381
|
--allow-private Allow private-network hosts (10.x, 192.168.x, ...)
|
|
44370
|
-
--no-forms
|
|
44371
|
-
|
|
45382
|
+
--no-forms Skip the form validation probe
|
|
45383
|
+
(by default, on local hosts, an empty submission is simulated: nothing that sends data
|
|
45384
|
+
leaves the browser, so no database is written; server-side feedback is reported as untested)
|
|
45385
|
+
--allow-submit Really send empty submissions to test server-side validation, on local or other hosts
|
|
45386
|
+
(disposable/test environments you are authorized to use only; never to delete/payment URLs)
|
|
44372
45387
|
--sarif Also write report.sarif for code scanning
|
|
44373
45388
|
--fail-on SEVERITY Exit 1 if a defect at or above this severity exists (CI gate)`,
|
|
44374
45389
|
run: (ctx) => auditCommand(ctx, audit.usage)
|
|
@@ -44390,11 +45405,11 @@ var conversion = {
|
|
|
44390
45405
|
|
|
44391
45406
|
// src/cli/commands/bench.ts
|
|
44392
45407
|
import { existsSync as existsSync7, readdirSync as readdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
|
|
44393
|
-
import { join as
|
|
45408
|
+
import { join as join10, resolve as resolve6 } from "node:path";
|
|
44394
45409
|
init_errors();
|
|
44395
|
-
var matches = (f, e) => f.ruleId === e.rule && pathOf(f.url).split("?")[0].replace(/\/$/, "") === e.path.replace(/\/$/, "") && (!e.viewport || (f.viewport ?? "").split(", ").includes(e.viewport));
|
|
45410
|
+
var matches = (f, e) => f.ruleId === e.rule && (!e.kind || f.kind === e.kind) && pathOf(f.url).split("?")[0].replace(/\/$/, "") === e.path.replace(/\/$/, "") && (!e.viewport || (f.viewport ?? "").split(", ").includes(e.viewport));
|
|
44396
45411
|
async function benchFixture(dir, wsRoot) {
|
|
44397
|
-
const expected = JSON.parse(readFileSync6(
|
|
45412
|
+
const expected = JSON.parse(readFileSync6(join10(dir, "expected.json"), "utf8"));
|
|
44398
45413
|
const server = await serveStatic(dir);
|
|
44399
45414
|
const t0 = Date.now();
|
|
44400
45415
|
try {
|
|
@@ -44409,6 +45424,7 @@ async function benchFixture(dir, wsRoot) {
|
|
|
44409
45424
|
headed: false,
|
|
44410
45425
|
respectRobots: true,
|
|
44411
45426
|
allowPrivate: false,
|
|
45427
|
+
allowSubmit: expected.audit?.allowSubmit === true,
|
|
44412
45428
|
keyboard: true,
|
|
44413
45429
|
throttle: false,
|
|
44414
45430
|
timeoutMs: 3e4,
|
|
@@ -44464,12 +45480,12 @@ var bench = {
|
|
|
44464
45480
|
const root = resolve6(ctx.positionals[0] ?? "fixtures/sites");
|
|
44465
45481
|
if (!existsSync7(root)) throw new JouriloError("NOT_FOUND", `No fixtures at ${root}`);
|
|
44466
45482
|
const only = str(ctx, "only");
|
|
44467
|
-
const dirs = readdirSync5(root).filter((d) => existsSync7(
|
|
45483
|
+
const dirs = readdirSync5(root).filter((d) => existsSync7(join10(root, d, "expected.json"))).filter((d) => !only || d === only);
|
|
44468
45484
|
if (!dirs.length) throw new JouriloError("NOT_FOUND", `No fixtures with expected.json in ${root}`);
|
|
44469
45485
|
const results = [];
|
|
44470
45486
|
for (const d of dirs) {
|
|
44471
45487
|
ctx.log(`bench: ${d}`);
|
|
44472
|
-
results.push(await benchFixture(
|
|
45488
|
+
results.push(await benchFixture(join10(root, d), join10(ctx.ws.root, "bench")));
|
|
44473
45489
|
}
|
|
44474
45490
|
const found = results.reduce((a, r) => a + r.found, 0);
|
|
44475
45491
|
const expected = results.reduce((a, r) => a + r.expected, 0);
|
|
@@ -44519,13 +45535,230 @@ var bench = {
|
|
|
44519
45535
|
};
|
|
44520
45536
|
|
|
44521
45537
|
// src/cli/commands/discover.ts
|
|
44522
|
-
import { existsSync as
|
|
44523
|
-
import { join as
|
|
45538
|
+
import { existsSync as existsSync10, statSync as statSync7, writeFileSync as writeFileSync6 } from "node:fs";
|
|
45539
|
+
import { join as join13, resolve as resolve7 } from "node:path";
|
|
44524
45540
|
|
|
44525
45541
|
// src/discovery/repo.ts
|
|
44526
45542
|
init_fs_safety();
|
|
45543
|
+
import { existsSync as existsSync9, readdirSync as readdirSync7, readFileSync as readFileSync8, statSync as statSync6 } from "node:fs";
|
|
45544
|
+
import { basename as basename3, dirname as dirname6, join as join12, relative as relative5 } from "node:path";
|
|
45545
|
+
|
|
45546
|
+
// src/discovery/laravel.ts
|
|
45547
|
+
init_fs_safety();
|
|
44527
45548
|
import { existsSync as existsSync8, readdirSync as readdirSync6, readFileSync as readFileSync7, statSync as statSync5 } from "node:fs";
|
|
44528
|
-
import { basename, dirname as dirname5, join as
|
|
45549
|
+
import { basename as basename2, dirname as dirname5, join as join11, relative as relative4 } from "node:path";
|
|
45550
|
+
function read(file) {
|
|
45551
|
+
try {
|
|
45552
|
+
return statSync5(file).size > 4e5 ? "" : readFileSync7(file, "utf8");
|
|
45553
|
+
} catch {
|
|
45554
|
+
return "";
|
|
45555
|
+
}
|
|
45556
|
+
}
|
|
45557
|
+
function stripComments(php) {
|
|
45558
|
+
return php.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")).replace(/(^|[^:'"\\])\/\/[^\n]*/g, (m, p) => p + " ".repeat(m.length - p.length));
|
|
45559
|
+
}
|
|
45560
|
+
function argValue(arg) {
|
|
45561
|
+
const lit = arg.match(/^\s*(['"])(.*?)\1\s*$/);
|
|
45562
|
+
if (lit) return lit[2];
|
|
45563
|
+
const ternary = arg.match(/\?\s*(['"])(.*?)\1\s*:\s*(['"])(.*?)\3/);
|
|
45564
|
+
if (ternary) return ternary[2] || ternary[4] || "";
|
|
45565
|
+
return null;
|
|
45566
|
+
}
|
|
45567
|
+
function joinPath(prefix, path2) {
|
|
45568
|
+
const p = `/${[prefix, path2].map((s) => s.replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/")}`;
|
|
45569
|
+
return p.replace(/\{(\w+)\??\}/g, ":$1");
|
|
45570
|
+
}
|
|
45571
|
+
var AUTH_MW = /^(auth(:[\w,]+)?|verified|password\.confirm|can:.+|[\w.]*\.auth|[\w.]*\bauthenticated)$/;
|
|
45572
|
+
function middlewares(chain) {
|
|
45573
|
+
const out = [];
|
|
45574
|
+
for (const m of chain.matchAll(/middleware\(\s*(\[[^\]]*\]|['"][^'"]+['"])/g))
|
|
45575
|
+
for (const s of m[1].matchAll(/['"]([^'"]+)['"]/g)) out.push(s[1]);
|
|
45576
|
+
return out;
|
|
45577
|
+
}
|
|
45578
|
+
function entryFiles(appRoot) {
|
|
45579
|
+
const out = /* @__PURE__ */ new Set();
|
|
45580
|
+
const add = (rel) => {
|
|
45581
|
+
const abs = join11(appRoot, rel);
|
|
45582
|
+
if (existsSync8(abs)) out.add(toPosix(relative4(appRoot, abs)));
|
|
45583
|
+
};
|
|
45584
|
+
add("routes/web.php");
|
|
45585
|
+
for (const f of ["bootstrap/app.php", "app/Providers/RouteServiceProvider.php"]) {
|
|
45586
|
+
const t = read(join11(appRoot, f));
|
|
45587
|
+
for (const m of t.matchAll(/web\s*:\s*__DIR__\s*\.\s*['"]([^'"]+)['"]/g)) add(join11(dirname5(f), m[1]));
|
|
45588
|
+
for (const m of t.matchAll(/base_path\(\s*['"](routes\/[^'"]+\.php)['"]\s*\)/g))
|
|
45589
|
+
if (!/routes\/(api|console|channels)\.php$/.test(m[1])) add(m[1]);
|
|
45590
|
+
}
|
|
45591
|
+
return [...out];
|
|
45592
|
+
}
|
|
45593
|
+
function scanFile(appRoot, rel, inherited, routes, seen) {
|
|
45594
|
+
if (seen.has(rel)) return;
|
|
45595
|
+
seen.add(rel);
|
|
45596
|
+
const text = stripComments(read(join11(appRoot, rel)));
|
|
45597
|
+
if (!text) return;
|
|
45598
|
+
const stack = [{ prefix: inherited.prefix, auth: inherited.auth, depth: -1 }];
|
|
45599
|
+
let depth = 0;
|
|
45600
|
+
let stmtStart = 0;
|
|
45601
|
+
let pendingGroup = null;
|
|
45602
|
+
const top = () => stack[stack.length - 1];
|
|
45603
|
+
for (let i = 0; i < text.length; i++) {
|
|
45604
|
+
const ch = text[i];
|
|
45605
|
+
if (ch === "'" || ch === '"') {
|
|
45606
|
+
const q = ch;
|
|
45607
|
+
for (i++; i < text.length && text[i] !== q; i++) if (text[i] === "\\") i++;
|
|
45608
|
+
continue;
|
|
45609
|
+
}
|
|
45610
|
+
if (ch === "{") {
|
|
45611
|
+
depth++;
|
|
45612
|
+
if (pendingGroup) {
|
|
45613
|
+
stack.push({ ...pendingGroup, depth });
|
|
45614
|
+
pendingGroup = null;
|
|
45615
|
+
}
|
|
45616
|
+
stmtStart = i + 1;
|
|
45617
|
+
continue;
|
|
45618
|
+
}
|
|
45619
|
+
if (ch === "}") {
|
|
45620
|
+
if (top().depth === depth && stack.length > 1) stack.pop();
|
|
45621
|
+
depth--;
|
|
45622
|
+
stmtStart = i + 1;
|
|
45623
|
+
continue;
|
|
45624
|
+
}
|
|
45625
|
+
if (ch === ";") {
|
|
45626
|
+
const stmt = text.slice(stmtStart, i);
|
|
45627
|
+
stmtStart = i + 1;
|
|
45628
|
+
handleStatement(stmt);
|
|
45629
|
+
continue;
|
|
45630
|
+
}
|
|
45631
|
+
if (text.startsWith("group(", i) && /->|::/.test(text.slice(Math.max(0, i - 2), i))) {
|
|
45632
|
+
const chain = text.slice(stmtStart, i);
|
|
45633
|
+
const rest = text.slice(i, i + 200);
|
|
45634
|
+
const included = rest.match(
|
|
45635
|
+
/^group\(\s*(?:__DIR__\s*\.\s*['"]([^'"]+)['"]|base_path\(\s*['"]([^'"]+)['"]\s*\))/
|
|
45636
|
+
);
|
|
45637
|
+
const g = groupOf(chain);
|
|
45638
|
+
if (included) {
|
|
45639
|
+
const target = included[1] ? join11(dirname5(rel), included[1]) : included[2];
|
|
45640
|
+
scanFile(appRoot, toPosix(target), g, routes, seen);
|
|
45641
|
+
} else if (/^group\(\s*(?:static\s+)?function/.test(rest)) pendingGroup = g;
|
|
45642
|
+
}
|
|
45643
|
+
}
|
|
45644
|
+
function groupOf(chain) {
|
|
45645
|
+
const pre = chain.match(/prefix\(([^)]*\)?)\)/);
|
|
45646
|
+
const value = pre ? argValue(pre[1]) : null;
|
|
45647
|
+
const auth = middlewares(chain).filter((m) => AUTH_MW.test(m));
|
|
45648
|
+
return {
|
|
45649
|
+
prefix: joinPath(top().prefix, value ?? "").replace(/^\/$/, ""),
|
|
45650
|
+
auth: [...top().auth, ...auth]
|
|
45651
|
+
};
|
|
45652
|
+
}
|
|
45653
|
+
function handleStatement(stmt) {
|
|
45654
|
+
const req = stmt.match(/^\s*(?:require|include)(?:_once)?\s*\(?\s*__DIR__\s*\.\s*['"]([^'"]+\.php)['"]/);
|
|
45655
|
+
if (req) {
|
|
45656
|
+
scanFile(appRoot, toPosix(join11(dirname5(rel), req[1])), top(), routes, seen);
|
|
45657
|
+
return;
|
|
45658
|
+
}
|
|
45659
|
+
const m = stmt.match(
|
|
45660
|
+
/\b(?:Route|Volt)::(get|view|any|match|livewire|inertia|redirect|permanentRedirect|route)\(\s*(?:\[[^\]]*\]\s*,\s*)?(['"])([^'"]*)\2/
|
|
45661
|
+
);
|
|
45662
|
+
if (!m) return;
|
|
45663
|
+
const verb = m[1];
|
|
45664
|
+
if (verb === "match" && !/['"]get['"]/i.test(stmt)) return;
|
|
45665
|
+
const via = verb === "livewire" ? "Livewire" : verb === "route" && /Volt::/.test(stmt) ? "Volt" : verb === "inertia" ? "Inertia" : verb === "redirect" || verb === "permanentRedirect" ? "redirect" : "Laravel";
|
|
45666
|
+
if (via === "redirect") return;
|
|
45667
|
+
const path2 = joinPath(top().prefix, m[3]);
|
|
45668
|
+
const own = middlewares(stmt).filter((x) => AUTH_MW.test(x));
|
|
45669
|
+
routes.push({ path: path2, file: rel, via, dynamic: /[:{]/.test(path2), auth: [...top().auth, ...own] });
|
|
45670
|
+
}
|
|
45671
|
+
}
|
|
45672
|
+
function kebab(name) {
|
|
45673
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
|
|
45674
|
+
}
|
|
45675
|
+
function plural(word) {
|
|
45676
|
+
if (/(s|x|z|ch|sh)$/.test(word)) return `${word}es`;
|
|
45677
|
+
if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`;
|
|
45678
|
+
return `${word}s`;
|
|
45679
|
+
}
|
|
45680
|
+
function phpFiles(dir, depth) {
|
|
45681
|
+
if (!existsSync8(dir)) return [];
|
|
45682
|
+
const out = [];
|
|
45683
|
+
for (const e of readdirSync6(dir, { withFileTypes: true })) {
|
|
45684
|
+
const full = join11(dir, e.name);
|
|
45685
|
+
if (e.isDirectory() && depth > 1) out.push(...phpFiles(full, depth - 1));
|
|
45686
|
+
else if (e.isFile() && e.name.endsWith(".php")) out.push(full);
|
|
45687
|
+
}
|
|
45688
|
+
return out.sort();
|
|
45689
|
+
}
|
|
45690
|
+
function filamentPanels(appRoot, routes) {
|
|
45691
|
+
const dir = join11(appRoot, "app/Providers/Filament");
|
|
45692
|
+
const files = existsSync8(dir) ? readdirSync6(dir).filter((f) => f.endsWith("PanelProvider.php")) : [];
|
|
45693
|
+
const panels = [];
|
|
45694
|
+
for (const f of files) {
|
|
45695
|
+
const rel = `app/Providers/Filament/${f}`;
|
|
45696
|
+
const t = stripComments(read(join11(appRoot, rel)));
|
|
45697
|
+
const id = t.match(/->id\(\s*['"]([^'"]+)['"]/)?.[1] ?? kebab(f.replace(/PanelProvider\.php$/, ""));
|
|
45698
|
+
const pathArg = t.match(/->path\(([^)]*\)?)\)/)?.[1];
|
|
45699
|
+
const path2 = joinPath("", (pathArg !== void 0 ? argValue(pathArg) : null) ?? id);
|
|
45700
|
+
const login = /->login\(/.test(t);
|
|
45701
|
+
panels.push({ id, path: path2, login, file: rel });
|
|
45702
|
+
const auth = ["filament"];
|
|
45703
|
+
routes.push({ path: path2, file: rel, via: "Filament", dynamic: false, auth });
|
|
45704
|
+
if (login)
|
|
45705
|
+
routes.push({ path: joinPath(path2, "login"), file: rel, via: "Filament", dynamic: false, auth: [] });
|
|
45706
|
+
for (const [sub, isResource] of [
|
|
45707
|
+
["app/Filament/Resources", true],
|
|
45708
|
+
["app/Filament/Pages", false]
|
|
45709
|
+
]) {
|
|
45710
|
+
for (const file of phpFiles(join11(appRoot, sub), 2)) {
|
|
45711
|
+
const name = basename2(file, ".php");
|
|
45712
|
+
if (isResource ? !name.endsWith("Resource") : name === "Dashboard") continue;
|
|
45713
|
+
const relFile = toPosix(join11(sub, relative4(join11(appRoot, sub), file)));
|
|
45714
|
+
if (!isResource && /\/(Concerns|Widgets)\//.test(relFile)) continue;
|
|
45715
|
+
const own = read(file).match(/static\s+(?:\?string\s+)?\$slug\s*=\s*['"]([^'"]+)['"]/)?.[1];
|
|
45716
|
+
const slug2 = own ?? (isResource ? plural(kebab(name.replace(/Resource$/, ""))) : kebab(name));
|
|
45717
|
+
routes.push({ path: joinPath(path2, slug2), file: relFile, via: "Filament", dynamic: false, auth });
|
|
45718
|
+
}
|
|
45719
|
+
}
|
|
45720
|
+
}
|
|
45721
|
+
return panels;
|
|
45722
|
+
}
|
|
45723
|
+
function topLevelKeys(php, name) {
|
|
45724
|
+
const start = php.search(new RegExp(`['"]${name}['"]\\s*=>\\s*\\[`));
|
|
45725
|
+
if (start < 0) return [];
|
|
45726
|
+
const open = php.indexOf("[", start);
|
|
45727
|
+
const keys = [];
|
|
45728
|
+
let depth = 0;
|
|
45729
|
+
for (let i = open; i < php.length; i++) {
|
|
45730
|
+
const ch = php[i];
|
|
45731
|
+
if (ch === "'" || ch === '"') {
|
|
45732
|
+
let j = i + 1;
|
|
45733
|
+
while (j < php.length && php[j] !== ch) j += php[j] === "\\" ? 2 : 1;
|
|
45734
|
+
if (depth === 1 && /^\s*=>/.test(php.slice(j + 1, j + 8))) keys.push(php.slice(i + 1, j));
|
|
45735
|
+
i = j;
|
|
45736
|
+
continue;
|
|
45737
|
+
}
|
|
45738
|
+
if (ch === "[") depth++;
|
|
45739
|
+
else if (ch === "]" && --depth === 0) break;
|
|
45740
|
+
}
|
|
45741
|
+
return keys;
|
|
45742
|
+
}
|
|
45743
|
+
function laravelProfile(appRoot) {
|
|
45744
|
+
const routes = [];
|
|
45745
|
+
const seen = /* @__PURE__ */ new Set();
|
|
45746
|
+
for (const f of entryFiles(appRoot)) scanFile(appRoot, f, { prefix: "", auth: [] }, routes, seen);
|
|
45747
|
+
const routesDir = join11(appRoot, "routes");
|
|
45748
|
+
if (existsSync8(routesDir)) {
|
|
45749
|
+
for (const f of readdirSync6(routesDir))
|
|
45750
|
+
if (f.endsWith(".php") && !/^(api|console|channels)\.php$/.test(f))
|
|
45751
|
+
scanFile(appRoot, `routes/${f}`, { prefix: "", auth: [] }, routes, seen);
|
|
45752
|
+
}
|
|
45753
|
+
const panels = filamentPanels(appRoot, routes);
|
|
45754
|
+
const authConfig = stripComments(read(join11(appRoot, "config/auth.php")));
|
|
45755
|
+
const guards = topLevelKeys(authConfig, "guards");
|
|
45756
|
+
const unique = /* @__PURE__ */ new Map();
|
|
45757
|
+
for (const r of routes) if (!unique.has(r.path)) unique.set(r.path, r);
|
|
45758
|
+
return { routes: [...unique.values()], guards, panels };
|
|
45759
|
+
}
|
|
45760
|
+
|
|
45761
|
+
// src/discovery/repo.ts
|
|
44529
45762
|
var SKIP = /* @__PURE__ */ new Set([
|
|
44530
45763
|
"node_modules",
|
|
44531
45764
|
".git",
|
|
@@ -44550,21 +45783,21 @@ function walk(root, max = 2e4) {
|
|
|
44550
45783
|
if (out.length >= max || depth > 14) return;
|
|
44551
45784
|
let entries;
|
|
44552
45785
|
try {
|
|
44553
|
-
entries =
|
|
45786
|
+
entries = readdirSync7(dir);
|
|
44554
45787
|
} catch {
|
|
44555
45788
|
return;
|
|
44556
45789
|
}
|
|
44557
45790
|
for (const n of entries) {
|
|
44558
45791
|
if (SKIP.has(n) || n.startsWith(".") && n !== ".github") continue;
|
|
44559
|
-
const full =
|
|
45792
|
+
const full = join12(dir, n);
|
|
44560
45793
|
let st;
|
|
44561
45794
|
try {
|
|
44562
|
-
st =
|
|
45795
|
+
st = statSync6(full);
|
|
44563
45796
|
} catch {
|
|
44564
45797
|
continue;
|
|
44565
45798
|
}
|
|
44566
45799
|
if (st.isDirectory()) rec(full, depth + 1);
|
|
44567
|
-
else out.push(toPosix(
|
|
45800
|
+
else out.push(toPosix(relative5(root, full)));
|
|
44568
45801
|
}
|
|
44569
45802
|
};
|
|
44570
45803
|
rec(root, 0);
|
|
@@ -44572,16 +45805,16 @@ function walk(root, max = 2e4) {
|
|
|
44572
45805
|
}
|
|
44573
45806
|
function readJson2(file) {
|
|
44574
45807
|
try {
|
|
44575
|
-
return JSON.parse(
|
|
45808
|
+
return JSON.parse(readFileSync8(file, "utf8"));
|
|
44576
45809
|
} catch {
|
|
44577
45810
|
return null;
|
|
44578
45811
|
}
|
|
44579
45812
|
}
|
|
44580
|
-
function
|
|
45813
|
+
function read2(file, max = 4e5) {
|
|
44581
45814
|
try {
|
|
44582
|
-
const st =
|
|
45815
|
+
const st = statSync6(file);
|
|
44583
45816
|
if (st.size > max) return "";
|
|
44584
|
-
return
|
|
45817
|
+
return readFileSync8(file, "utf8");
|
|
44585
45818
|
} catch {
|
|
44586
45819
|
return "";
|
|
44587
45820
|
}
|
|
@@ -44689,7 +45922,7 @@ function fileRoutes(appRoot, files, frameworks) {
|
|
|
44689
45922
|
[
|
|
44690
45923
|
"Next.js",
|
|
44691
45924
|
/^(?:src\/)?pages\/(.+)\.(tsx|jsx|ts|js|mdx|md)$/,
|
|
44692
|
-
(m) => /^_(app|document|error)/.test(
|
|
45925
|
+
(m) => /^_(app|document|error)/.test(basename3(m[1])) ? null : { segs: m[1].split("/"), kind: m[1].startsWith("api/") ? "api" : "page" }
|
|
44693
45926
|
],
|
|
44694
45927
|
["Nuxt", /^(?:app\/)?pages\/(.+)\.vue$/, (m) => ({ segs: m[1].split("/"), kind: "page" })],
|
|
44695
45928
|
[
|
|
@@ -44737,7 +45970,6 @@ function fileRoutes(appRoot, files, frameworks) {
|
|
|
44737
45970
|
const codeRoute = [
|
|
44738
45971
|
[/\.(t|j)sx?$/, /<Route\s[^>]*path=["'`]([^"'`]+)["'`]/g, "React Router"],
|
|
44739
45972
|
[/\.(t|j)sx?$|\.vue$/, /\bpath:\s*["'`](\/[^"'`]*)["'`]/g, "Router config"],
|
|
44740
|
-
[/routes\/web\.php$/, /Route::(?:get|view|match|any)\(\s*['"]([^'"]+)['"]/g, "Laravel"],
|
|
44741
45973
|
[
|
|
44742
45974
|
/\.dart$/,
|
|
44743
45975
|
/GoRoute\(\s*(?:name:\s*['"][^'"]*['"]\s*,\s*)?path:\s*['"]([^'"]+)['"]/g,
|
|
@@ -44748,7 +45980,7 @@ function fileRoutes(appRoot, files, frameworks) {
|
|
|
44748
45980
|
for (const f of files) {
|
|
44749
45981
|
for (const [fileRe, re, label] of codeRoute) {
|
|
44750
45982
|
if (!fileRe.test(f)) continue;
|
|
44751
|
-
const text =
|
|
45983
|
+
const text = read2(join12(appRoot, f));
|
|
44752
45984
|
if (!text) continue;
|
|
44753
45985
|
for (const m of text.matchAll(re)) {
|
|
44754
45986
|
const raw = m[1].trim();
|
|
@@ -44759,6 +45991,9 @@ function fileRoutes(appRoot, files, frameworks) {
|
|
|
44759
45991
|
}
|
|
44760
45992
|
}
|
|
44761
45993
|
}
|
|
45994
|
+
if (has("Laravel"))
|
|
45995
|
+
for (const r of laravelProfile(appRoot).routes)
|
|
45996
|
+
add(r.path, r.file, "page", r.via === "Laravel" ? "Laravel" : `Laravel (${r.via})`, r.dynamic);
|
|
44762
45997
|
return routes.sort((a, b) => a.path.localeCompare(b.path));
|
|
44763
45998
|
}
|
|
44764
45999
|
function devCommand(appRoot, pkg, frameworks, rel) {
|
|
@@ -44780,7 +46015,7 @@ function devCommand(appRoot, pkg, frameworks, rel) {
|
|
|
44780
46015
|
};
|
|
44781
46016
|
const def = frameworks.map((f) => defaults[f]).find(Boolean) ?? null;
|
|
44782
46017
|
if (script) {
|
|
44783
|
-
const lock =
|
|
46018
|
+
const lock = existsSync9(join12(appRoot, "pnpm-lock.yaml")) ? "pnpm" : existsSync9(join12(appRoot, "yarn.lock")) ? "yarn" : existsSync9(join12(appRoot, "bun.lockb")) || existsSync9(join12(appRoot, "bun.lock")) ? "bun" : "npm";
|
|
44784
46019
|
return {
|
|
44785
46020
|
command: lock,
|
|
44786
46021
|
args: ["run", script],
|
|
@@ -44788,9 +46023,9 @@ function devCommand(appRoot, pkg, frameworks, rel) {
|
|
|
44788
46023
|
source: `${rel || "."}/package.json`
|
|
44789
46024
|
};
|
|
44790
46025
|
}
|
|
44791
|
-
if (
|
|
46026
|
+
if (existsSync9(join12(appRoot, "artisan")))
|
|
44792
46027
|
return { command: "php", args: ["artisan", "serve"], port: 8e3, source: `${rel || "."}/artisan` };
|
|
44793
|
-
if (
|
|
46028
|
+
if (existsSync9(join12(appRoot, "pubspec.yaml")) && existsSync9(join12(appRoot, "web")))
|
|
44794
46029
|
return {
|
|
44795
46030
|
command: "flutter",
|
|
44796
46031
|
args: ["run", "-d", "web-server", "--web-port", "8080"],
|
|
@@ -44800,9 +46035,9 @@ function devCommand(appRoot, pkg, frameworks, rel) {
|
|
|
44800
46035
|
return null;
|
|
44801
46036
|
}
|
|
44802
46037
|
function profileApp(repoRoot, appRoot, files) {
|
|
44803
|
-
const rel = toPosix(
|
|
44804
|
-
const pkgFile =
|
|
44805
|
-
const pkg =
|
|
46038
|
+
const rel = toPosix(relative5(repoRoot, appRoot));
|
|
46039
|
+
const pkgFile = join12(appRoot, "package.json");
|
|
46040
|
+
const pkg = existsSync9(pkgFile) ? readJson2(pkgFile) : null;
|
|
44806
46041
|
const deps = {
|
|
44807
46042
|
...pkg?.dependencies ?? {},
|
|
44808
46043
|
...pkg?.devDependencies ?? {}
|
|
@@ -44813,14 +46048,14 @@ function profileApp(repoRoot, appRoot, files) {
|
|
|
44813
46048
|
if (s.group === "framework" && s.dep.test(d) && !frameworks.some((f) => f.value === s.label))
|
|
44814
46049
|
frameworks.push(fact(s.label, `${rel || "."}/package.json`));
|
|
44815
46050
|
}
|
|
44816
|
-
const composer =
|
|
46051
|
+
const composer = existsSync9(join12(appRoot, "composer.json")) ? readJson2(join12(appRoot, "composer.json")) : null;
|
|
44817
46052
|
if (composer && JSON.stringify(composer.require ?? {}).includes("laravel/framework"))
|
|
44818
46053
|
frameworks.push(fact("Laravel", `${rel || "."}/composer.json`));
|
|
44819
|
-
const pubspec =
|
|
46054
|
+
const pubspec = read2(join12(appRoot, "pubspec.yaml"));
|
|
44820
46055
|
if (/\bflutter:\s*\n\s+sdk:\s*flutter/.test(pubspec))
|
|
44821
46056
|
frameworks.push(
|
|
44822
46057
|
fact(
|
|
44823
|
-
|
|
46058
|
+
existsSync9(join12(appRoot, "web")) ? "Flutter (web enabled)" : "Flutter",
|
|
44824
46059
|
`${rel || "."}/pubspec.yaml`
|
|
44825
46060
|
)
|
|
44826
46061
|
);
|
|
@@ -44836,48 +46071,49 @@ function profileApp(repoRoot, appRoot, files) {
|
|
|
44836
46071
|
);
|
|
44837
46072
|
return {
|
|
44838
46073
|
root: rel || ".",
|
|
44839
|
-
name: pkg?.name ??
|
|
46074
|
+
name: pkg?.name ?? basename3(appRoot),
|
|
44840
46075
|
frameworks,
|
|
44841
46076
|
routes,
|
|
44842
46077
|
devCommand: devCommand(appRoot, pkg, names, rel),
|
|
44843
46078
|
components: {
|
|
44844
46079
|
count: componentFiles.length,
|
|
44845
|
-
dirs: [...new Set(componentFiles.map((f) =>
|
|
46080
|
+
dirs: [...new Set(componentFiles.map((f) => dirname6(f)))].slice(0, 12)
|
|
44846
46081
|
}
|
|
44847
46082
|
};
|
|
44848
46083
|
}
|
|
44849
46084
|
function discoverRepository(root) {
|
|
44850
46085
|
const files = walk(root);
|
|
44851
46086
|
const gaps = [];
|
|
44852
|
-
const rootPkg =
|
|
46087
|
+
const rootPkg = existsSync9(join12(root, "package.json")) ? readJson2(join12(root, "package.json")) : null;
|
|
44853
46088
|
let monorepo = null;
|
|
44854
46089
|
const appRoots = [];
|
|
44855
46090
|
const ws = rootPkg?.workspaces;
|
|
44856
46091
|
const wsGlobs = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : [];
|
|
44857
|
-
const pnpm =
|
|
46092
|
+
const pnpm = read2(join12(root, "pnpm-workspace.yaml"));
|
|
44858
46093
|
for (const m of pnpm.matchAll(/^\s*-\s*['"]?([^'"\n]+)['"]?\s*$/gm)) wsGlobs.push(m[1]);
|
|
44859
46094
|
if (wsGlobs.length)
|
|
44860
46095
|
monorepo = fact(
|
|
44861
46096
|
`workspaces: ${wsGlobs.join(", ")}`,
|
|
44862
|
-
|
|
46097
|
+
existsSync9(join12(root, "pnpm-workspace.yaml")) ? "pnpm-workspace.yaml" : "package.json"
|
|
44863
46098
|
);
|
|
44864
46099
|
for (const g of wsGlobs) {
|
|
44865
46100
|
const base2 = g.replace(/\/\*\*?$/, "");
|
|
44866
46101
|
if (g.endsWith("*")) {
|
|
44867
|
-
const dir =
|
|
44868
|
-
if (!
|
|
44869
|
-
for (const n of
|
|
44870
|
-
if (
|
|
44871
|
-
} else if (
|
|
46102
|
+
const dir = join12(root, base2);
|
|
46103
|
+
if (!existsSync9(dir)) continue;
|
|
46104
|
+
for (const n of readdirSync7(dir))
|
|
46105
|
+
if (existsSync9(join12(dir, n, "package.json"))) appRoots.push(join12(dir, n));
|
|
46106
|
+
} else if (existsSync9(join12(root, base2, "package.json"))) appRoots.push(join12(root, base2));
|
|
44872
46107
|
}
|
|
44873
46108
|
const apps = (appRoots.length ? appRoots : [root]).map((r) => profileApp(root, r, files)).filter(
|
|
44874
46109
|
(a, _, all) => all.length === 1 || a.routes.length > 0 || a.frameworks.some((f) => !["React", "Vue", "Svelte", "Vite"].includes(f.value))
|
|
44875
46110
|
);
|
|
44876
46111
|
if (!apps.length) apps.push(profileApp(root, root, files));
|
|
46112
|
+
const laravelRoles = [];
|
|
44877
46113
|
const allPkgs = files.filter((f) => f.endsWith("package.json")).slice(0, 60);
|
|
44878
46114
|
const signals = /* @__PURE__ */ new Map();
|
|
44879
46115
|
for (const p of allPkgs) {
|
|
44880
|
-
const j = readJson2(
|
|
46116
|
+
const j = readJson2(join12(root, p));
|
|
44881
46117
|
const deps = Object.keys({
|
|
44882
46118
|
...j?.dependencies ?? {},
|
|
44883
46119
|
...j?.devDependencies ?? {}
|
|
@@ -44890,11 +46126,31 @@ function discoverRepository(root) {
|
|
|
44890
46126
|
signals.set(s.group, list2);
|
|
44891
46127
|
}
|
|
44892
46128
|
}
|
|
44893
|
-
const composer =
|
|
46129
|
+
const composer = existsSync9(join12(root, "composer.json")) ? JSON.stringify(readJson2(join12(root, "composer.json"))?.require ?? {}) : "";
|
|
44894
46130
|
if (/laravel\/(breeze|fortify|jetstream|sanctum)/.test(composer))
|
|
44895
46131
|
(signals.get("auth") ?? signals.set("auth", []).get("auth")).push(
|
|
44896
46132
|
fact(composer.match(/laravel\/(breeze|fortify|jetstream|sanctum)/)[0], "composer.json")
|
|
44897
46133
|
);
|
|
46134
|
+
for (const app of apps.filter((a) => a.frameworks.some((f) => f.value === "Laravel"))) {
|
|
46135
|
+
const lp = laravelProfile(join12(root, app.root));
|
|
46136
|
+
const at = (f) => app.root === "." ? f : `${app.root}/${f}`;
|
|
46137
|
+
const authList = signals.get("auth") ?? signals.set("auth", []).get("auth");
|
|
46138
|
+
if (lp.guards.length)
|
|
46139
|
+
authList.push(fact(`Laravel guards: ${lp.guards.join(", ")}`, at("config/auth.php")));
|
|
46140
|
+
for (const p of lp.panels.filter((x) => x.login))
|
|
46141
|
+
authList.push(fact(`Filament panel "${p.id}" with login at ${p.path}/login`, at(p.file)));
|
|
46142
|
+
const protectedRoutes = lp.routes.filter((r) => r.auth.length && r.via !== "Filament");
|
|
46143
|
+
if (protectedRoutes.length)
|
|
46144
|
+
authList.push(
|
|
46145
|
+
fact(
|
|
46146
|
+
`${protectedRoutes.length} route(s) behind ${[...new Set(protectedRoutes.flatMap((r) => r.auth))].join(", ")}`,
|
|
46147
|
+
at(protectedRoutes[0].file)
|
|
46148
|
+
)
|
|
46149
|
+
);
|
|
46150
|
+
for (const g of lp.guards.filter((x) => !["web", "api", "sanctum"].includes(x)))
|
|
46151
|
+
laravelRoles.push(fact(g, at("config/auth.php")));
|
|
46152
|
+
for (const p of lp.panels) laravelRoles.push(fact(`${p.id} panel user`, at(p.file)));
|
|
46153
|
+
}
|
|
44898
46154
|
if (/laravel\/cashier/.test(composer))
|
|
44899
46155
|
(signals.get("payments") ?? signals.set("payments", []).get("payments")).push(
|
|
44900
46156
|
fact("laravel/cashier", "composer.json")
|
|
@@ -44904,9 +46160,9 @@ function discoverRepository(root) {
|
|
|
44904
46160
|
let cssVariables = 0;
|
|
44905
46161
|
for (const f of files) {
|
|
44906
46162
|
if (/(^|\/)(tokens?|design-tokens|theme)\.(json|ts|js|css)$/i.test(f) || /\.tokens\.json$/.test(f))
|
|
44907
|
-
tokens.push(fact(
|
|
46163
|
+
tokens.push(fact(basename3(f), f));
|
|
44908
46164
|
if (/\.(css|scss)$/.test(f)) {
|
|
44909
|
-
const t =
|
|
46165
|
+
const t = read2(join12(root, f), 3e5);
|
|
44910
46166
|
cssVariables += (t.match(/--[a-z0-9-]+\s*:/gi) ?? []).length;
|
|
44911
46167
|
for (const m of t.matchAll(/font-family\s*:\s*["']?([A-Za-z][\w -]+?)["']?\s*[,;]/g)) {
|
|
44912
46168
|
const name = m[1].trim();
|
|
@@ -44915,7 +46171,7 @@ function discoverRepository(root) {
|
|
|
44915
46171
|
}
|
|
44916
46172
|
}
|
|
44917
46173
|
if (/\.(tsx|jsx|ts|js)$/.test(f) && /layout|_app|_document/.test(f)) {
|
|
44918
|
-
const t =
|
|
46174
|
+
const t = read2(join12(root, f));
|
|
44919
46175
|
for (const m of t.matchAll(/import\s*\{\s*([A-Z][\w,\s]+)\}\s*from\s*['"]next\/font\/google['"]/g))
|
|
44920
46176
|
for (const n of m[1].split(",").map((s) => s.trim()).filter(Boolean))
|
|
44921
46177
|
if (!fonts.some((x) => x.value === n)) fonts.push(fact(n.replace(/_/g, " "), f));
|
|
@@ -44939,8 +46195,9 @@ function discoverRepository(root) {
|
|
|
44939
46195
|
fact("signed-in user", allRoutes.find((r) => r.role === "dashboard" || r.role === "settings").file)
|
|
44940
46196
|
);
|
|
44941
46197
|
roles.unshift(fact("visitor", "public routes"));
|
|
46198
|
+
for (const r of laravelRoles) if (!roles.some((x) => x.value === r.value)) roles.push(r);
|
|
44942
46199
|
for (const f of files.filter((x) => /\.(ts|tsx|js|php|dart|rb|py)$/.test(x)).slice(0, 3e3)) {
|
|
44943
|
-
const t =
|
|
46200
|
+
const t = read2(join12(root, f), 12e4);
|
|
44944
46201
|
for (const m of t.matchAll(
|
|
44945
46202
|
/\brole\s*(?:===?|==|:)\s*['"](owner|admin|editor|viewer|member|manager|customer|seller|vendor|teacher|student)['"]/gi
|
|
44946
46203
|
)) {
|
|
@@ -44953,11 +46210,12 @@ function discoverRepository(root) {
|
|
|
44953
46210
|
const cj = (name, rs, why) => {
|
|
44954
46211
|
if (rs.length) candidateJourneys.push({ name, routes: [...new Set(rs)].slice(0, 6), why });
|
|
44955
46212
|
};
|
|
44956
|
-
|
|
44957
|
-
|
|
44958
|
-
|
|
44959
|
-
|
|
44960
|
-
|
|
46213
|
+
if (byRole("signup").length)
|
|
46214
|
+
cj(
|
|
46215
|
+
"Sign up and reach the product",
|
|
46216
|
+
[...byRole("signup"), ...byRole("dashboard")],
|
|
46217
|
+
byRole("dashboard").length ? "signup and dashboard routes exist" : "signup route exists"
|
|
46218
|
+
);
|
|
44961
46219
|
cj("Sign in", byRole("login"), "login route exists");
|
|
44962
46220
|
cj("Recover access", byRole("recovery"), "password recovery route exists");
|
|
44963
46221
|
cj(
|
|
@@ -44972,7 +46230,7 @@ function discoverRepository(root) {
|
|
|
44972
46230
|
let readmeSummary = null;
|
|
44973
46231
|
const readmeFile = files.find((f) => /^readme\.md$/i.test(f));
|
|
44974
46232
|
if (readmeFile) {
|
|
44975
|
-
const para =
|
|
46233
|
+
const para = read2(join12(root, readmeFile)).split(/\n\s*\n/).map((p) => p.replace(/<[^>]+>|!\[[^\]]*\]\([^)]*\)|\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)/g, "").trim()).find((p) => p.length > 60 && !p.startsWith("#") && !p.startsWith("```") && !p.startsWith("|"));
|
|
44976
46234
|
if (para) readmeSummary = fact(para.replace(/\s+/g, " ").slice(0, 400), readmeFile);
|
|
44977
46235
|
}
|
|
44978
46236
|
const description = typeof rootPkg?.description === "string" ? fact(rootPkg.description, "package.json") : null;
|
|
@@ -44996,7 +46254,7 @@ function discoverRepository(root) {
|
|
|
44996
46254
|
schemaVersion: 1,
|
|
44997
46255
|
kind: "repository",
|
|
44998
46256
|
root,
|
|
44999
|
-
name: rootPkg?.name ??
|
|
46257
|
+
name: rootPkg?.name ?? basename3(root),
|
|
45000
46258
|
description,
|
|
45001
46259
|
readmeSummary,
|
|
45002
46260
|
monorepo,
|
|
@@ -45169,9 +46427,9 @@ var discover = {
|
|
|
45169
46427
|
async run(ctx) {
|
|
45170
46428
|
const target = requirePositional(ctx, 0, "path|url", discover.usage);
|
|
45171
46429
|
ctx.ws.ensure();
|
|
45172
|
-
if (!target.includes("://") &&
|
|
46430
|
+
if (!target.includes("://") && existsSync10(target) && statSync7(target).isDirectory()) {
|
|
45173
46431
|
const profile2 = discoverRepository(resolve7(target));
|
|
45174
|
-
const file2 =
|
|
46432
|
+
const file2 = join13(ctx.ws.root, "product.json");
|
|
45175
46433
|
writeFileSync6(file2, `${JSON.stringify(profile2, null, 2)}
|
|
45176
46434
|
`);
|
|
45177
46435
|
return { data: { file: file2, profile: profile2 }, text: `${repoText(profile2)}
|
|
@@ -45196,7 +46454,7 @@ var discover = {
|
|
|
45196
46454
|
onProgress: ctx.log
|
|
45197
46455
|
});
|
|
45198
46456
|
const profile = buildSiteProfile(run, observations);
|
|
45199
|
-
const file =
|
|
46457
|
+
const file = join13(ctx.ws.root, "site.json");
|
|
45200
46458
|
writeFileSync6(file, `${JSON.stringify(profile, null, 2)}
|
|
45201
46459
|
`);
|
|
45202
46460
|
return { data: { file, profile }, text: `${siteText(profile)}
|
|
@@ -45207,8 +46465,8 @@ var discover = {
|
|
|
45207
46465
|
|
|
45208
46466
|
// src/cli/commands/experiment.ts
|
|
45209
46467
|
init_errors();
|
|
45210
|
-
import { existsSync as
|
|
45211
|
-
import { join as
|
|
46468
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync4, readFileSync as readFileSync9, writeFileSync as writeFileSync7 } from "node:fs";
|
|
46469
|
+
import { join as join14 } from "node:path";
|
|
45212
46470
|
|
|
45213
46471
|
// src/experiment/stats.ts
|
|
45214
46472
|
function normCdf(z) {
|
|
@@ -45630,19 +46888,19 @@ Jourilo never invents traffic, conversions or effects: every number comes from y
|
|
|
45630
46888
|
if (!f) throw new JouriloError("NOT_FOUND", `Finding ${fp} is not in run ${run.id}.`);
|
|
45631
46889
|
if (f.kind !== "hypothesis")
|
|
45632
46890
|
ctx.log(`Note: ${f.ruleId} is a ${f.kind}; defects should usually be fixed, not A/B tested.`);
|
|
45633
|
-
const productFile =
|
|
45634
|
-
const product =
|
|
46891
|
+
const productFile = join14(ctx.ws.root, "product.json");
|
|
46892
|
+
const product = existsSync11(productFile) ? JSON.parse(readFileSync9(productFile, "utf8")) : null;
|
|
45635
46893
|
const plan = planExperiment(run.id, f, {
|
|
45636
46894
|
...numOpt(ctx, "baseline") !== void 0 ? { baseline: numOpt(ctx, "baseline") } : {},
|
|
45637
46895
|
...numOpt(ctx, "mde") !== void 0 ? { mde: numOpt(ctx, "mde") } : {},
|
|
45638
46896
|
...numOpt(ctx, "daily-traffic") !== void 0 ? { dailyTraffic: numOpt(ctx, "daily-traffic") } : {},
|
|
45639
46897
|
product
|
|
45640
46898
|
});
|
|
45641
|
-
const outDir =
|
|
46899
|
+
const outDir = join14(ctx.ws.root, "experiments");
|
|
45642
46900
|
mkdirSync4(outDir, { recursive: true });
|
|
45643
|
-
const md2 =
|
|
46901
|
+
const md2 = join14(outDir, `${plan.id}.md`);
|
|
45644
46902
|
writeFileSync7(md2, planMarkdown(plan));
|
|
45645
|
-
writeFileSync7(
|
|
46903
|
+
writeFileSync7(join14(outDir, `${plan.id}.json`), `${JSON.stringify(plan, null, 2)}
|
|
45646
46904
|
`);
|
|
45647
46905
|
return { data: { plan, file: md2 }, text: `${planMarkdown(plan)}
|
|
45648
46906
|
Saved: ${md2}` };
|
|
@@ -45665,8 +46923,8 @@ Saved: ${md2}` };
|
|
|
45665
46923
|
};
|
|
45666
46924
|
|
|
45667
46925
|
// src/cli/commands/finding.ts
|
|
45668
|
-
import { existsSync as
|
|
45669
|
-
import { join as
|
|
46926
|
+
import { existsSync as existsSync12 } from "node:fs";
|
|
46927
|
+
import { join as join15 } from "node:path";
|
|
45670
46928
|
init_errors();
|
|
45671
46929
|
init_fs_safety();
|
|
45672
46930
|
var CATEGORIES = [
|
|
@@ -45724,7 +46982,7 @@ var finding = {
|
|
|
45724
46982
|
"At least one --evidence file from the run (e.g. screenshots/home-\u2026-desktop.jpg) is required: judgments must point at what they judge."
|
|
45725
46983
|
);
|
|
45726
46984
|
for (const e of evidenceFiles)
|
|
45727
|
-
if (!
|
|
46985
|
+
if (!existsSync12(resolveInside(dir, e)))
|
|
45728
46986
|
throw new JouriloError("NOT_FOUND", `Evidence ${e} is not in run ${run.id}.`);
|
|
45729
46987
|
const url = str(ctx, "url") ?? run.pages[0]?.url ?? run.target;
|
|
45730
46988
|
const f = makeFinding({
|
|
@@ -45761,20 +47019,20 @@ var finding = {
|
|
|
45761
47019
|
writeReports(dir, run, void 0, { history: ctx.ws.listRuns() });
|
|
45762
47020
|
return {
|
|
45763
47021
|
data: f,
|
|
45764
|
-
text: `Added ${f.provenance} finding ${f.fingerprint} to run ${run.id} (${
|
|
47022
|
+
text: `Added ${f.provenance} finding ${f.fingerprint} to run ${run.id} (${join15(dir, "report.html")}).`
|
|
45765
47023
|
};
|
|
45766
47024
|
}
|
|
45767
47025
|
};
|
|
45768
47026
|
|
|
45769
47027
|
// src/cli/commands/journeys.ts
|
|
45770
|
-
import { existsSync as
|
|
45771
|
-
import { basename as
|
|
47028
|
+
import { existsSync as existsSync15, mkdirSync as mkdirSync7, readdirSync as readdirSync10, readFileSync as readFileSync12, writeFileSync as writeFileSync9 } from "node:fs";
|
|
47029
|
+
import { basename as basename5, join as join19, resolve as resolve8 } from "node:path";
|
|
45772
47030
|
init_errors();
|
|
45773
47031
|
init_fs_safety();
|
|
45774
47032
|
init_types();
|
|
45775
47033
|
|
|
45776
47034
|
// src/journeys/runner.ts
|
|
45777
|
-
import { join as
|
|
47035
|
+
import { join as join16 } from "node:path";
|
|
45778
47036
|
init_fs_safety();
|
|
45779
47037
|
|
|
45780
47038
|
// src/journeys/schema.ts
|
|
@@ -45887,15 +47145,61 @@ function locatorFor(page, t) {
|
|
|
45887
47145
|
return page.locator(t.css);
|
|
45888
47146
|
}
|
|
45889
47147
|
var ELEMENT_FACTS = `(el) => {
|
|
45890
|
-
|
|
45891
|
-
const
|
|
45892
|
-
const
|
|
47148
|
+
if (!el || el === document.body || el === document.documentElement) return null;
|
|
47149
|
+
const act = el.closest('a[href],button,input,select,textarea,summary,[role=button],[role=link],[role=menuitem],[role=tab],[role=option],[role=checkbox],[role=switch]') || el;
|
|
47150
|
+
const form = act.form || act.closest('form');
|
|
47151
|
+
const tag = act.tagName;
|
|
47152
|
+
const type = (act.getAttribute('type') || '').toLowerCase();
|
|
47153
|
+
const role = (act.getAttribute('role') || '').toLowerCase();
|
|
45893
47154
|
const submits = !!form && ((tag === 'BUTTON' && (type === '' || type === 'submit')) || (tag === 'INPUT' && (type === 'submit' || type === 'image')));
|
|
45894
|
-
const inField = !!form && (tag === 'INPUT' || tag === 'SELECT');
|
|
45895
|
-
const
|
|
45896
|
-
const
|
|
45897
|
-
|
|
47155
|
+
const inField = !!form && ((tag === 'INPUT' && !['button','submit','reset','checkbox','radio','image','file','range','color'].includes(type)) || tag === 'SELECT');
|
|
47156
|
+
const textarea = tag === 'TEXTAREA' || act.isContentEditable;
|
|
47157
|
+
const buttonLike = tag === 'BUTTON' || tag === 'SUMMARY' || (tag === 'INPUT' && ['button','submit','reset','image'].includes(type)) || ['button','menuitem','tab','option','switch','checkbox'].includes(role);
|
|
47158
|
+
const linkLike = (tag === 'A' && act.hasAttribute('href')) || role === 'link';
|
|
47159
|
+
const payment = !!form && !!form.querySelector('input[autocomplete^="cc-"], input[name*="card" i], input[name*="cvc" i], input[name*="cvv" i], input[data-stripe], iframe[src*="stripe"], iframe[name*="card" i], iframe[title*="card" i]');
|
|
47160
|
+
const byIds = (attr) => (act.getAttribute(attr) || '').split(/\\s+/).map((id) => id && document.getElementById(id)).filter(Boolean).map((n) => n.innerText || n.textContent || '').join(' ');
|
|
47161
|
+
const inner = Array.from(act.querySelectorAll('img[alt], [aria-label], svg title')).map((n) => n.getAttribute('alt') || n.getAttribute('aria-label') || n.textContent || '').join(' ');
|
|
47162
|
+
const name = (act.getAttribute('aria-label') || byIds('aria-labelledby') || act.innerText || act.value || act.getAttribute('title') || act.getAttribute('alt') || inner || '').replace(/\\s+/g, ' ').trim().slice(0, 120);
|
|
47163
|
+
const attrs = [];
|
|
47164
|
+
for (const n of [act, ...Array.from(act.querySelectorAll('*')).slice(0, 20)])
|
|
47165
|
+
for (const a of Array.from(n.attributes))
|
|
47166
|
+
if (/^(id|class|name|title|data-[\\w-]+)$/.test(a.name) || ((a.name === 'href' || a.name === 'xlink:href') && n.tagName.toLowerCase() === 'use'))
|
|
47167
|
+
attrs.push(a.value);
|
|
47168
|
+
const targets = [];
|
|
47169
|
+
if (linkLike && act.getAttribute('href')) targets.push(act.href || act.getAttribute('href'));
|
|
47170
|
+
if (act.getAttribute('formaction')) targets.push(act.formAction || act.getAttribute('formaction'));
|
|
47171
|
+
if (form && (submits || inField)) targets.push(form.getAttribute('action') || '');
|
|
47172
|
+
return { submits, inField, textarea, buttonLike, linkLike, payment, name, signals: attrs.join(' ').slice(0, 1500), targets: targets.join(' ') };
|
|
45898
47173
|
}`;
|
|
47174
|
+
function keyEffect(key, f) {
|
|
47175
|
+
if (!f) return "none";
|
|
47176
|
+
const k = key.split("+").pop();
|
|
47177
|
+
const mods = key.includes("+");
|
|
47178
|
+
const enter = k === "Enter" || k === "NumpadEnter";
|
|
47179
|
+
const space = k === " " || k === "Space";
|
|
47180
|
+
if (enter) {
|
|
47181
|
+
if (f.textarea) return mods ? "submit" : "none";
|
|
47182
|
+
if (f.inField) return "submit";
|
|
47183
|
+
if (f.buttonLike || f.linkLike) return f.submits ? "submit" : "activate";
|
|
47184
|
+
return "activate";
|
|
47185
|
+
}
|
|
47186
|
+
if (space && f.buttonLike) return f.submits ? "submit" : "activate";
|
|
47187
|
+
return "none";
|
|
47188
|
+
}
|
|
47189
|
+
function verdictFor(f, effect, action, policy) {
|
|
47190
|
+
const submitsForm = effect === "submit" || f.submits;
|
|
47191
|
+
return checkAction(
|
|
47192
|
+
{
|
|
47193
|
+
action,
|
|
47194
|
+
name: f.name,
|
|
47195
|
+
signals: f.signals,
|
|
47196
|
+
targets: f.targets,
|
|
47197
|
+
submitsForm,
|
|
47198
|
+
paymentForm: f.payment && submitsForm
|
|
47199
|
+
},
|
|
47200
|
+
policy
|
|
47201
|
+
);
|
|
47202
|
+
}
|
|
45899
47203
|
var facts = (src) => new Function(`return (${src})(arguments[0])`);
|
|
45900
47204
|
function matchesUrl(actual, expected) {
|
|
45901
47205
|
if (expected.startsWith("re:")) {
|
|
@@ -45925,34 +47229,31 @@ async function act(page, step, opts) {
|
|
|
45925
47229
|
switch (step.action) {
|
|
45926
47230
|
case "goto": {
|
|
45927
47231
|
const url = new URL(value(step.url), opts.base);
|
|
45928
|
-
if (url.origin !== opts.base.origin)
|
|
45929
|
-
throw new
|
|
47232
|
+
if (url.origin !== opts.base.origin && !(opts.allowOrigins ?? []).includes(url.origin))
|
|
47233
|
+
throw new Blocked(`goto ${url.origin} leaves the target origin ${opts.base.origin}`);
|
|
45930
47234
|
await page.goto(url.toString(), { waitUntil: "load", timeout: t * 3 });
|
|
45931
47235
|
await page.waitForLoadState("networkidle", { timeout: 3e3 }).catch(() => void 0);
|
|
45932
47236
|
return;
|
|
45933
47237
|
}
|
|
45934
47238
|
case "click":
|
|
45935
47239
|
case "press": {
|
|
45936
|
-
const loc = step.
|
|
47240
|
+
const loc = step.target ? locatorFor(page, step.target).first() : null;
|
|
47241
|
+
let handle = null;
|
|
45937
47242
|
if (loc) {
|
|
45938
47243
|
await loc.waitFor({ state: "visible", timeout: t });
|
|
45939
|
-
|
|
45940
|
-
|
|
45941
|
-
|
|
45942
|
-
|
|
45943
|
-
|
|
45944
|
-
|
|
45945
|
-
|
|
45946
|
-
|
|
45947
|
-
},
|
|
45948
|
-
opts.policy
|
|
45949
|
-
);
|
|
47244
|
+
handle = await loc.elementHandle({ timeout: t });
|
|
47245
|
+
} else {
|
|
47246
|
+
handle = (await page.evaluateHandle("document.activeElement")).asElement();
|
|
47247
|
+
}
|
|
47248
|
+
const f = handle ? await handle.evaluate(facts(ELEMENT_FACTS)) : null;
|
|
47249
|
+
const effect = step.action === "click" ? f?.submits ? "submit" : "activate" : keyEffect(step.key, f);
|
|
47250
|
+
if (f && effect !== "none") {
|
|
47251
|
+
const verdict = verdictFor(f, effect, step.action, opts.policy);
|
|
45950
47252
|
if (!verdict.allowed) throw new Blocked(verdict.reason);
|
|
45951
|
-
if (step.action === "click") await loc.click({ timeout: t });
|
|
45952
|
-
else await loc.press(step.key, { timeout: t });
|
|
45953
|
-
} else if (step.action === "press") {
|
|
45954
|
-
await page.keyboard.press(step.key);
|
|
45955
47253
|
}
|
|
47254
|
+
if (step.action === "click") await handle.click({ timeout: t });
|
|
47255
|
+
else if (loc && handle) await handle.press(step.key, { timeout: t });
|
|
47256
|
+
else await page.keyboard.press(step.key);
|
|
45956
47257
|
await page.waitForLoadState("networkidle", { timeout: 3e3 }).catch(() => void 0);
|
|
45957
47258
|
return;
|
|
45958
47259
|
}
|
|
@@ -46004,10 +47305,17 @@ async function runJourney(browser, spec, opts) {
|
|
|
46004
47305
|
runDir: opts.runDir,
|
|
46005
47306
|
...opts.storageState ? { storageState: opts.storageState } : {}
|
|
46006
47307
|
});
|
|
46007
|
-
const tracePath = opts.trace ?
|
|
47308
|
+
const tracePath = opts.trace ? join16("traces", `${spec.id}-${vp.name}.zip`) : void 0;
|
|
46008
47309
|
if (tracePath) await ctx.tracing.start({ screenshots: true, snapshots: true, title: spec.name });
|
|
47310
|
+
const guard = await installRequestGuard(ctx, {
|
|
47311
|
+
...opts.policy,
|
|
47312
|
+
origin: opts.base.origin,
|
|
47313
|
+
...opts.allowOrigins ? { allowOrigins: opts.allowOrigins } : {},
|
|
47314
|
+
mode: "journey"
|
|
47315
|
+
});
|
|
46009
47316
|
const page = await ctx.newPage();
|
|
46010
47317
|
const live = observe(page, opts.base.origin);
|
|
47318
|
+
live.ignore(guard.isBlocked);
|
|
46011
47319
|
const steps = [];
|
|
46012
47320
|
const findings = [];
|
|
46013
47321
|
const t0 = Date.now();
|
|
@@ -46015,10 +47323,10 @@ async function runJourney(browser, spec, opts) {
|
|
|
46015
47323
|
const startUrl = new URL(interpolate(spec.start, opts.data), opts.base);
|
|
46016
47324
|
const allSteps = [{ action: "goto", url: startUrl.toString() }, ...spec.steps];
|
|
46017
47325
|
const shot = async (i) => {
|
|
46018
|
-
const file =
|
|
47326
|
+
const file = join16("screenshots", `journey-${spec.id}-${vp.name}-${String(i).padStart(2, "0")}.jpg`);
|
|
46019
47327
|
try {
|
|
46020
47328
|
await page.screenshot({
|
|
46021
|
-
path:
|
|
47329
|
+
path: join16(opts.runDir, file),
|
|
46022
47330
|
type: "jpeg",
|
|
46023
47331
|
quality: 70,
|
|
46024
47332
|
animations: "disabled",
|
|
@@ -46057,6 +47365,14 @@ async function runJourney(browser, spec, opts) {
|
|
|
46057
47365
|
res.status = err instanceof Blocked ? "blocked" : "failed";
|
|
46058
47366
|
res.error = excerpt(err.message.split("\n")[0], 300);
|
|
46059
47367
|
}
|
|
47368
|
+
const stopped = guard.since(s0).filter((b) => b.navigation || b.kind !== "submit");
|
|
47369
|
+
if (stopped.length && res.status !== "blocked") {
|
|
47370
|
+
res.status = "blocked";
|
|
47371
|
+
res.error = excerpt(
|
|
47372
|
+
`${stopped[0].reason}${stopped.length > 1 ? ` (+${stopped.length - 1} more)` : ""}`,
|
|
47373
|
+
300
|
|
47374
|
+
);
|
|
47375
|
+
}
|
|
46060
47376
|
res.durationMs = Date.now() - s0;
|
|
46061
47377
|
res.url = redactUrl(page.url());
|
|
46062
47378
|
const s = await shot(i);
|
|
@@ -46115,7 +47431,7 @@ async function runJourney(browser, spec, opts) {
|
|
|
46115
47431
|
const vitals = await page.evaluate(READ_VITALS_SCRIPT).catch(() => null);
|
|
46116
47432
|
const durationMs = Date.now() - t0;
|
|
46117
47433
|
if (tracePath) {
|
|
46118
|
-
await ctx.tracing.stop({ path:
|
|
47434
|
+
await ctx.tracing.stop({ path: join16(opts.runDir, tracePath) });
|
|
46119
47435
|
}
|
|
46120
47436
|
if (opts.saveAuth && status === "passed") {
|
|
46121
47437
|
writeFileSafe(opts.saveAuth, JSON.stringify(await ctx.storageState(), null, 2), { private: true });
|
|
@@ -46128,8 +47444,10 @@ async function runJourney(browser, spec, opts) {
|
|
|
46128
47444
|
steps,
|
|
46129
47445
|
successCriteria: criteria,
|
|
46130
47446
|
durationMs,
|
|
46131
|
-
...tracePath ? { trace: toPosix(tracePath) } : {}
|
|
47447
|
+
...tracePath ? { trace: toPosix(tracePath) } : {},
|
|
47448
|
+
...guard.blocked.length ? { blockedRequests: guard.blocked.map(({ method, url: url2, reason }) => ({ method, url: url2, reason })) } : {}
|
|
46132
47449
|
};
|
|
47450
|
+
const blockedNote = guard.blocked.length ? ` The safety policy stopped ${guard.blocked.length} request(s) during this journey (e.g. ${guard.blocked[0].method} ${guard.blocked[0].url}), which may be why.` : "";
|
|
46133
47451
|
const reproduction = steps.filter((s) => s.status !== "skipped").map((s) => s.description);
|
|
46134
47452
|
const failedStep = steps.find((s) => s.status === "failed");
|
|
46135
47453
|
const blockedStep = steps.find((s) => s.status === "blocked");
|
|
@@ -46149,7 +47467,9 @@ async function runJourney(browser, spec, opts) {
|
|
|
46149
47467
|
provenance: "observed",
|
|
46150
47468
|
url,
|
|
46151
47469
|
viewport: vp.name,
|
|
46152
|
-
description: failedStep ? `Step ${failedStep.index + 1} (${failedStep.description}) failed: ${failedStep.error}` : `All steps ran, but ${unmet.length} success criterion/criteria were not met: ${unmet.map((c) => `${c.description}${c.detail ? ` (${c.detail})` : ""}`).join("; ")}
|
|
47470
|
+
description: `${failedStep ? `Step ${failedStep.index + 1} (${failedStep.description}) failed: ${failedStep.error}` : `All steps ran, but ${unmet.length} success criterion/criteria were not met: ${unmet.map((c) => `${c.description}${c.detail ? ` (${c.detail})` : ""}`).join("; ")}.`}${blockedNote}`,
|
|
47471
|
+
// A goal missed because Jourilo itself stopped a request is not evidence that the site is broken.
|
|
47472
|
+
...blockedNote ? { confidence: "low" } : {},
|
|
46153
47473
|
impact: spec.goal ? `People trying to ${spec.goal} cannot complete it.` : "People cannot complete this task.",
|
|
46154
47474
|
recommendation: spec.source === "suggested" ? "Check whether the journey definition matches the intended flow (it was generated); if it does, fix the step that failed." : "Fix the step that failed, then rerun the journey.",
|
|
46155
47475
|
standards: [nielsen(9)],
|
|
@@ -46402,33 +47722,382 @@ function suggestJourneys(run) {
|
|
|
46402
47722
|
return out;
|
|
46403
47723
|
}
|
|
46404
47724
|
|
|
47725
|
+
// src/repair/git.ts
|
|
47726
|
+
init_errors();
|
|
47727
|
+
init_fs_safety();
|
|
47728
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
47729
|
+
import { existsSync as existsSync14, mkdirSync as mkdirSync6, readdirSync as readdirSync9, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "node:fs";
|
|
47730
|
+
import { dirname as dirname8, join as join18 } from "node:path";
|
|
47731
|
+
|
|
47732
|
+
// src/repair/transaction.ts
|
|
47733
|
+
init_errors();
|
|
47734
|
+
init_fs_safety();
|
|
47735
|
+
import { createHash as createHash2, randomBytes as randomBytes2 } from "node:crypto";
|
|
47736
|
+
import {
|
|
47737
|
+
closeSync,
|
|
47738
|
+
existsSync as existsSync13,
|
|
47739
|
+
fsyncSync,
|
|
47740
|
+
lstatSync,
|
|
47741
|
+
mkdirSync as mkdirSync5,
|
|
47742
|
+
openSync,
|
|
47743
|
+
readdirSync as readdirSync8,
|
|
47744
|
+
readFileSync as readFileSync10,
|
|
47745
|
+
renameSync,
|
|
47746
|
+
rmSync,
|
|
47747
|
+
statSync as statSync8,
|
|
47748
|
+
unlinkSync,
|
|
47749
|
+
writeSync
|
|
47750
|
+
} from "node:fs";
|
|
47751
|
+
import { basename as basename4, dirname as dirname7, join as join17 } from "node:path";
|
|
47752
|
+
var sha256 = (b) => createHash2("sha256").update(b).digest("hex");
|
|
47753
|
+
var recordsDir = (wsRoot) => join17(wsRoot, "checkpoints");
|
|
47754
|
+
var txDir = (wsRoot, id) => join17(recordsDir(wsRoot), id);
|
|
47755
|
+
function atomicWrite(path2, data, mode) {
|
|
47756
|
+
mkdirSync5(dirname7(path2), { recursive: true });
|
|
47757
|
+
const tmp = join17(dirname7(path2), `.${basename4(path2)}.jourilo-${randomBytes2(4).toString("hex")}.tmp`);
|
|
47758
|
+
const fd = openSync(tmp, "wx", mode ?? 420);
|
|
47759
|
+
try {
|
|
47760
|
+
let off = 0;
|
|
47761
|
+
while (off < data.length) off += writeSync(fd, data, off, data.length - off);
|
|
47762
|
+
fsyncSync(fd);
|
|
47763
|
+
} finally {
|
|
47764
|
+
closeSync(fd);
|
|
47765
|
+
}
|
|
47766
|
+
for (let attempt = 0; ; attempt++) {
|
|
47767
|
+
try {
|
|
47768
|
+
renameSync(tmp, path2);
|
|
47769
|
+
return;
|
|
47770
|
+
} catch (err) {
|
|
47771
|
+
if (attempt >= 5 || !["EPERM", "EBUSY", "EACCES"].includes(err.code ?? "")) {
|
|
47772
|
+
rmSync(tmp, { force: true });
|
|
47773
|
+
throw err;
|
|
47774
|
+
}
|
|
47775
|
+
const until = Date.now() + 50 * (attempt + 1);
|
|
47776
|
+
while (Date.now() < until) ;
|
|
47777
|
+
}
|
|
47778
|
+
}
|
|
47779
|
+
}
|
|
47780
|
+
function saveRecord(wsRoot, tx) {
|
|
47781
|
+
mkdirSync5(recordsDir(wsRoot), { recursive: true });
|
|
47782
|
+
atomicWrite(join17(recordsDir(wsRoot), `${tx.id}.json`), Buffer.from(`${JSON.stringify(tx, null, 2)}
|
|
47783
|
+
`));
|
|
47784
|
+
}
|
|
47785
|
+
function writablePath(root, file) {
|
|
47786
|
+
const abs = resolveInside(root, file);
|
|
47787
|
+
const lexical = join17(root, file);
|
|
47788
|
+
if (existsSync13(lexical) && lstatSync(lexical).isSymbolicLink())
|
|
47789
|
+
throw new JouriloError(
|
|
47790
|
+
"UNSAFE_PATH",
|
|
47791
|
+
`${file} is a symbolic link; Jourilo does not write through links. Make this change by hand.`
|
|
47792
|
+
);
|
|
47793
|
+
return abs;
|
|
47794
|
+
}
|
|
47795
|
+
function readOrNull(path2) {
|
|
47796
|
+
return existsSync13(path2) ? readFileSync10(path2) : null;
|
|
47797
|
+
}
|
|
47798
|
+
function applyTransaction(root, wsRoot, edits, opts = {}) {
|
|
47799
|
+
const pending = activeTransactions(wsRoot).filter((t) => t.state === "pending");
|
|
47800
|
+
if (pending.length)
|
|
47801
|
+
throw new JouriloError(
|
|
47802
|
+
"INVALID_INPUT",
|
|
47803
|
+
`An earlier change (${pending[0].id}) was interrupted before it finished.`,
|
|
47804
|
+
`Run \`jourilo rollback ${pending[0].id} --yes\` to restore those files first.`
|
|
47805
|
+
);
|
|
47806
|
+
const names = edits.map((e) => e.file);
|
|
47807
|
+
const dup = names.find((n, i) => names.indexOf(n) !== i);
|
|
47808
|
+
if (dup) throw new JouriloError("INTERNAL", `${dup} appears twice in one change; combine its edits first.`);
|
|
47809
|
+
const prepared = edits.map((e) => {
|
|
47810
|
+
const abs = writablePath(root, e.file);
|
|
47811
|
+
const current = readOrNull(abs);
|
|
47812
|
+
const same = e.before === null ? current === null : current?.equals(e.before);
|
|
47813
|
+
if (!same)
|
|
47814
|
+
throw new JouriloError(
|
|
47815
|
+
"INVALID_INPUT",
|
|
47816
|
+
`${e.file} changed after the fix was planned; nothing was written.`,
|
|
47817
|
+
"Run the command again so the plan is computed from the current file."
|
|
47818
|
+
);
|
|
47819
|
+
return { ...e, abs, mode: current ? statSync8(abs).mode & 511 : null };
|
|
47820
|
+
});
|
|
47821
|
+
const tx = {
|
|
47822
|
+
kind: "fix-transaction",
|
|
47823
|
+
id: newRunId(),
|
|
47824
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
47825
|
+
root,
|
|
47826
|
+
note: opts.note ?? "",
|
|
47827
|
+
...opts.undoes ? { undoes: opts.undoes } : {},
|
|
47828
|
+
state: "pending",
|
|
47829
|
+
files: prepared.map((p) => ({
|
|
47830
|
+
file: p.file,
|
|
47831
|
+
beforeSha: p.before ? sha256(p.before) : null,
|
|
47832
|
+
afterSha: p.after ? sha256(p.after) : null,
|
|
47833
|
+
mode: p.mode,
|
|
47834
|
+
state: "pending"
|
|
47835
|
+
}))
|
|
47836
|
+
};
|
|
47837
|
+
for (const p of prepared)
|
|
47838
|
+
if (p.before) atomicWrite(resolveInside(join17(txDir(wsRoot, tx.id), "before"), p.file), p.before);
|
|
47839
|
+
saveRecord(wsRoot, tx);
|
|
47840
|
+
try {
|
|
47841
|
+
prepared.forEach((p, i) => {
|
|
47842
|
+
opts.beforeWrite?.(p.file, i);
|
|
47843
|
+
if (p.after) atomicWrite(p.abs, p.after, p.mode);
|
|
47844
|
+
else if (existsSync13(p.abs)) unlinkSync(p.abs);
|
|
47845
|
+
tx.files[i].state = "written";
|
|
47846
|
+
saveRecord(wsRoot, tx);
|
|
47847
|
+
});
|
|
47848
|
+
} catch (err) {
|
|
47849
|
+
const undo = rollbackTransaction(wsRoot, tx, { force: false });
|
|
47850
|
+
tx.state = "failed";
|
|
47851
|
+
saveRecord(wsRoot, tx);
|
|
47852
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
47853
|
+
throw new JouriloError(
|
|
47854
|
+
"INTERNAL",
|
|
47855
|
+
`Writing the changes failed (${msg}); ${undo.restored.length} file(s) already written were restored.${undo.conflicts.length ? ` Not restored: ${undo.conflicts.map((c) => c.file).join(", ")}.` : ""}`
|
|
47856
|
+
);
|
|
47857
|
+
}
|
|
47858
|
+
tx.state = "applied";
|
|
47859
|
+
saveRecord(wsRoot, tx);
|
|
47860
|
+
return tx;
|
|
47861
|
+
}
|
|
47862
|
+
function rollbackTransaction(wsRoot, tx, opts = {}) {
|
|
47863
|
+
const result = { restored: [], alreadyRestored: [], conflicts: [] };
|
|
47864
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
47865
|
+
for (const f of tx.files) {
|
|
47866
|
+
if (opts.files?.length && !opts.files.includes(f.file)) continue;
|
|
47867
|
+
if (f.state === "restored") {
|
|
47868
|
+
result.alreadyRestored.push(f.file);
|
|
47869
|
+
continue;
|
|
47870
|
+
}
|
|
47871
|
+
const abs = writablePath(tx.root, f.file);
|
|
47872
|
+
const current = readOrNull(abs);
|
|
47873
|
+
const currentSha = current ? sha256(current) : null;
|
|
47874
|
+
if (currentSha === f.beforeSha) {
|
|
47875
|
+
f.state = "restored";
|
|
47876
|
+
delete f.conflict;
|
|
47877
|
+
result.alreadyRestored.push(f.file);
|
|
47878
|
+
continue;
|
|
47879
|
+
}
|
|
47880
|
+
if (currentSha !== f.afterSha) {
|
|
47881
|
+
const reason = current === null ? "deleted after the change was applied" : "modified after the change was applied (not by Jourilo)";
|
|
47882
|
+
if (!opts.force) {
|
|
47883
|
+
f.state = "conflict";
|
|
47884
|
+
f.conflict = reason;
|
|
47885
|
+
result.conflicts.push({ file: f.file, reason });
|
|
47886
|
+
continue;
|
|
47887
|
+
}
|
|
47888
|
+
let savedCopy;
|
|
47889
|
+
if (current) {
|
|
47890
|
+
savedCopy = resolveInside(join17(txDir(wsRoot, tx.id), "conflicts", stamp), f.file);
|
|
47891
|
+
atomicWrite(savedCopy, current);
|
|
47892
|
+
}
|
|
47893
|
+
result.conflicts.push({ file: f.file, reason, ...savedCopy ? { savedCopy } : {} });
|
|
47894
|
+
}
|
|
47895
|
+
if (f.beforeSha === null) {
|
|
47896
|
+
if (existsSync13(abs)) unlinkSync(abs);
|
|
47897
|
+
} else {
|
|
47898
|
+
const backup = resolveInside(join17(txDir(wsRoot, tx.id), "before"), f.file);
|
|
47899
|
+
if (!existsSync13(backup)) {
|
|
47900
|
+
f.state = "conflict";
|
|
47901
|
+
f.conflict = "backup missing";
|
|
47902
|
+
result.conflicts.push({ file: f.file, reason: "backup missing; cannot restore" });
|
|
47903
|
+
continue;
|
|
47904
|
+
}
|
|
47905
|
+
const bytes = readFileSync10(backup);
|
|
47906
|
+
if (sha256(bytes) !== f.beforeSha) {
|
|
47907
|
+
result.conflicts.push({ file: f.file, reason: "backup does not match its recorded hash" });
|
|
47908
|
+
continue;
|
|
47909
|
+
}
|
|
47910
|
+
atomicWrite(abs, bytes, f.mode);
|
|
47911
|
+
}
|
|
47912
|
+
f.state = "restored";
|
|
47913
|
+
delete f.conflict;
|
|
47914
|
+
result.restored.push(f.file);
|
|
47915
|
+
}
|
|
47916
|
+
const allBack = tx.files.every((f) => f.state === "restored");
|
|
47917
|
+
if (tx.state !== "pending" || allBack) tx.state = allBack ? "rolled-back" : "partially-rolled-back";
|
|
47918
|
+
saveRecord(wsRoot, tx);
|
|
47919
|
+
return result;
|
|
47920
|
+
}
|
|
47921
|
+
function isTransaction(x) {
|
|
47922
|
+
return !!x && typeof x === "object" && x.kind === "fix-transaction";
|
|
47923
|
+
}
|
|
47924
|
+
function activeTransactions(wsRoot) {
|
|
47925
|
+
const dir = recordsDir(wsRoot);
|
|
47926
|
+
if (!existsSync13(dir)) return [];
|
|
47927
|
+
const out = [];
|
|
47928
|
+
for (const f of readdirSync8(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
47929
|
+
try {
|
|
47930
|
+
const rec = JSON.parse(readFileSync10(join17(dir, f), "utf8"));
|
|
47931
|
+
if (isTransaction(rec) && ["pending", "applied", "partially-rolled-back"].includes(rec.state))
|
|
47932
|
+
out.push(rec);
|
|
47933
|
+
} catch {
|
|
47934
|
+
}
|
|
47935
|
+
}
|
|
47936
|
+
return out;
|
|
47937
|
+
}
|
|
47938
|
+
function previewRollback(tx) {
|
|
47939
|
+
return tx.files.map((f) => {
|
|
47940
|
+
if (f.state === "restored") return { file: f.file, action: "unchanged" };
|
|
47941
|
+
const abs = writablePath(tx.root, f.file);
|
|
47942
|
+
const current = readOrNull(abs);
|
|
47943
|
+
const sha = current ? sha256(current) : null;
|
|
47944
|
+
if (sha === f.beforeSha) return { file: f.file, action: "unchanged" };
|
|
47945
|
+
if (sha === f.afterSha) return { file: f.file, action: "restore" };
|
|
47946
|
+
return {
|
|
47947
|
+
file: f.file,
|
|
47948
|
+
action: "conflict",
|
|
47949
|
+
reason: current === null ? "deleted after the change" : "modified after the change"
|
|
47950
|
+
};
|
|
47951
|
+
});
|
|
47952
|
+
}
|
|
47953
|
+
|
|
47954
|
+
// src/repair/git.ts
|
|
47955
|
+
function git(cwd, args) {
|
|
47956
|
+
const r = spawnSync2("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
47957
|
+
return { ok: r.status === 0, out: (r.stdout ?? "").trim(), err: (r.stderr ?? "").trim() };
|
|
47958
|
+
}
|
|
47959
|
+
function isGitRepo(root) {
|
|
47960
|
+
return git(root, ["rev-parse", "--is-inside-work-tree"]).out === "true";
|
|
47961
|
+
}
|
|
47962
|
+
function untrackedFiles(root) {
|
|
47963
|
+
return git(root, ["ls-files", "--others", "--exclude-standard"]).out.split("\n").filter(Boolean);
|
|
47964
|
+
}
|
|
47965
|
+
function createCheckpoint(root, wsRoot, note = "") {
|
|
47966
|
+
if (!isGitRepo(root))
|
|
47967
|
+
throw new JouriloError(
|
|
47968
|
+
"INVALID_INPUT",
|
|
47969
|
+
`${root} is not a git repository; checkpoints need git.`,
|
|
47970
|
+
"Run `git init` and commit, or keep your own backup before applying fixes."
|
|
47971
|
+
);
|
|
47972
|
+
const head = git(root, ["rev-parse", "HEAD"]);
|
|
47973
|
+
if (!head.ok)
|
|
47974
|
+
throw new JouriloError(
|
|
47975
|
+
"INVALID_INPUT",
|
|
47976
|
+
"The repository has no commits yet; commit once before using checkpoints."
|
|
47977
|
+
);
|
|
47978
|
+
const stash = git(root, ["stash", "create", `jourilo checkpoint ${note}`.trim()]);
|
|
47979
|
+
const cp = {
|
|
47980
|
+
id: newRunId(),
|
|
47981
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
47982
|
+
head: head.out,
|
|
47983
|
+
snapshot: stash.out || head.out,
|
|
47984
|
+
untracked: untrackedFiles(root),
|
|
47985
|
+
note
|
|
47986
|
+
};
|
|
47987
|
+
git(root, ["update-ref", `refs/jourilo/checkpoints/${cp.id}`, cp.snapshot]);
|
|
47988
|
+
const dir = join18(wsRoot, "checkpoints");
|
|
47989
|
+
mkdirSync6(dir, { recursive: true });
|
|
47990
|
+
writeFileSync8(join18(dir, `${cp.id}.json`), `${JSON.stringify(cp, null, 2)}
|
|
47991
|
+
`);
|
|
47992
|
+
return cp;
|
|
47993
|
+
}
|
|
47994
|
+
function loadCheckpoint(wsRoot, id) {
|
|
47995
|
+
const dir = join18(wsRoot, "checkpoints");
|
|
47996
|
+
const read3 = (name) => JSON.parse(readFileSync11(join18(dir, `${name}.json`), "utf8"));
|
|
47997
|
+
let pick = id;
|
|
47998
|
+
if (id === "latest") {
|
|
47999
|
+
const names = existsSync14(dir) ? readdirSync9(dir).filter((f) => f.endsWith(".json")).map((f) => f.replace(/\.json$/, "")).sort().reverse() : [];
|
|
48000
|
+
pick = names.find((n) => {
|
|
48001
|
+
try {
|
|
48002
|
+
const rec = read3(n);
|
|
48003
|
+
if (!isTransaction(rec)) return !rec.rolledBack;
|
|
48004
|
+
return !rec.undoes && ["applied", "pending", "partially-rolled-back"].includes(rec.state);
|
|
48005
|
+
} catch {
|
|
48006
|
+
return false;
|
|
48007
|
+
}
|
|
48008
|
+
});
|
|
48009
|
+
}
|
|
48010
|
+
if (!pick || !/^[\w-]+$/.test(pick) || !existsSync14(join18(dir, `${pick}.json`)))
|
|
48011
|
+
throw new JouriloError("NOT_FOUND", `Checkpoint not found: ${id}`);
|
|
48012
|
+
return read3(pick);
|
|
48013
|
+
}
|
|
48014
|
+
function diffSince(root, cp) {
|
|
48015
|
+
const stat = git(root, ["diff", "--stat", cp.snapshot]).out;
|
|
48016
|
+
const patch = git(root, ["diff", cp.snapshot]).out;
|
|
48017
|
+
const newFiles = untrackedFiles(root).filter((f) => !cp.untracked.includes(f));
|
|
48018
|
+
return { stat, patch, newFiles };
|
|
48019
|
+
}
|
|
48020
|
+
function backupDir(wsRoot, cp) {
|
|
48021
|
+
return join18(wsRoot, "checkpoints", cp.id);
|
|
48022
|
+
}
|
|
48023
|
+
function blob(root, rev, file) {
|
|
48024
|
+
const r = spawnSync2("git", ["cat-file", "blob", `${rev}:${file}`], {
|
|
48025
|
+
cwd: root,
|
|
48026
|
+
maxBuffer: 64 * 1024 * 1024
|
|
48027
|
+
});
|
|
48028
|
+
return r.status === 0 ? r.stdout : null;
|
|
48029
|
+
}
|
|
48030
|
+
function rollbackCheckpoint(root, cp, wsRoot, opts = {}) {
|
|
48031
|
+
const changed = git(root, ["diff", "--name-only", "--no-renames", cp.snapshot]).out.split("\n").filter(Boolean).filter((f) => !opts.files?.length || opts.files.includes(f));
|
|
48032
|
+
const edits = [];
|
|
48033
|
+
const notRestorable = [];
|
|
48034
|
+
for (const f of changed) {
|
|
48035
|
+
const target = resolveInside(root, f);
|
|
48036
|
+
const current = existsSync14(target) ? readFileSync11(target) : null;
|
|
48037
|
+
const saved = resolveInside(backupDir(wsRoot, cp), f);
|
|
48038
|
+
let wanted = existsSync14(saved) ? readFileSync11(saved) : null;
|
|
48039
|
+
if (!wanted) {
|
|
48040
|
+
const content = blob(root, cp.snapshot, f);
|
|
48041
|
+
if (!content) {
|
|
48042
|
+
notRestorable.push(f);
|
|
48043
|
+
continue;
|
|
48044
|
+
}
|
|
48045
|
+
wanted = current?.includes("\r\n") && !content.includes("\r\n") ? Buffer.from(content.toString("utf8").replace(/\n/g, "\r\n"), "utf8") : content;
|
|
48046
|
+
}
|
|
48047
|
+
if (current?.equals(wanted)) continue;
|
|
48048
|
+
edits.push({ file: f, before: current, after: wanted });
|
|
48049
|
+
}
|
|
48050
|
+
const transaction = edits.length ? applyTransaction(root, wsRoot, edits, { note: `rollback to checkpoint ${cp.id}`, undoes: cp.id }) : null;
|
|
48051
|
+
return {
|
|
48052
|
+
transaction,
|
|
48053
|
+
restored: edits.map((e) => e.file),
|
|
48054
|
+
// Files that did not exist at checkpoint time are listed, never deleted.
|
|
48055
|
+
leftUntracked: [
|
|
48056
|
+
...notRestorable,
|
|
48057
|
+
...untrackedFiles(root).filter((f) => !cp.untracked.includes(f) && !notRestorable.includes(f))
|
|
48058
|
+
]
|
|
48059
|
+
};
|
|
48060
|
+
}
|
|
48061
|
+
function markRolledBack(wsRoot, cp, transactionId) {
|
|
48062
|
+
const file = join18(wsRoot, "checkpoints", `${cp.id}.json`);
|
|
48063
|
+
writeFileSync8(file, `${JSON.stringify({ ...cp, rolledBack: transactionId }, null, 2)}
|
|
48064
|
+
`);
|
|
48065
|
+
}
|
|
48066
|
+
function wouldBeCommitted(file) {
|
|
48067
|
+
let dir = dirname8(file);
|
|
48068
|
+
while (!existsSync14(dir) && dirname8(dir) !== dir) dir = dirname8(dir);
|
|
48069
|
+
if (!isGitRepo(dir)) return false;
|
|
48070
|
+
const r = spawnSync2("git", ["check-ignore", "-q", "--no-index", file], { cwd: dir });
|
|
48071
|
+
return r.status === 1;
|
|
48072
|
+
}
|
|
48073
|
+
|
|
46405
48074
|
// src/cli/commands/journeys.ts
|
|
46406
48075
|
function journeysDir(ws) {
|
|
46407
|
-
return
|
|
48076
|
+
return join19(ws.root, "journeys");
|
|
46408
48077
|
}
|
|
46409
48078
|
function loadJourneys(ws, refs) {
|
|
46410
48079
|
const dir = journeysDir(ws);
|
|
46411
48080
|
const files = [];
|
|
46412
48081
|
if (!refs.length) {
|
|
46413
|
-
if (!
|
|
48082
|
+
if (!existsSync15(dir)) return [];
|
|
46414
48083
|
files.push(
|
|
46415
|
-
...
|
|
48084
|
+
...readdirSync10(dir).filter((f) => f.endsWith(".json")).sort().map((f) => join19(dir, f))
|
|
46416
48085
|
);
|
|
46417
48086
|
} else {
|
|
46418
48087
|
for (const r of refs) {
|
|
46419
|
-
if (r.endsWith(".json") &&
|
|
46420
|
-
else if (
|
|
48088
|
+
if (r.endsWith(".json") && existsSync15(r)) files.push(resolve8(r));
|
|
48089
|
+
else if (existsSync15(join19(dir, `${r}.json`))) files.push(join19(dir, `${r}.json`));
|
|
46421
48090
|
else throw new JouriloError("NOT_FOUND", `Journey not found: ${r} (looked in ${dir})`);
|
|
46422
48091
|
}
|
|
46423
48092
|
}
|
|
46424
48093
|
return files.map((f) => {
|
|
46425
48094
|
let raw;
|
|
46426
48095
|
try {
|
|
46427
|
-
raw = JSON.parse(
|
|
48096
|
+
raw = JSON.parse(readFileSync12(f, "utf8"));
|
|
46428
48097
|
} catch (err) {
|
|
46429
|
-
throw new JouriloError("INVALID_INPUT", `${
|
|
48098
|
+
throw new JouriloError("INVALID_INPUT", `${basename5(f)} is not valid JSON: ${err.message}`);
|
|
46430
48099
|
}
|
|
46431
|
-
return validateJourney(raw,
|
|
48100
|
+
return validateJourney(raw, basename5(f));
|
|
46432
48101
|
});
|
|
46433
48102
|
}
|
|
46434
48103
|
function parseVars(ctx) {
|
|
@@ -46448,7 +48117,7 @@ async function suggest(ctx) {
|
|
|
46448
48117
|
if (!target || ctx.ws.listRuns().some((r) => r.id === target) || target === "latest") {
|
|
46449
48118
|
run = ctx.ws.loadRun(target ?? "latest").run;
|
|
46450
48119
|
} else {
|
|
46451
|
-
const isDir =
|
|
48120
|
+
const isDir = existsSync15(target) && !target.includes("://");
|
|
46452
48121
|
const server = isDir ? await serveStatic(resolve8(target)) : void 0;
|
|
46453
48122
|
try {
|
|
46454
48123
|
ctx.log("Discovering pages to derive journeys (no audit rules applied) \u2026");
|
|
@@ -46475,16 +48144,16 @@ async function suggest(ctx) {
|
|
|
46475
48144
|
}
|
|
46476
48145
|
const specs = suggestJourneys(run);
|
|
46477
48146
|
const dir = journeysDir(ctx.ws);
|
|
46478
|
-
|
|
48147
|
+
mkdirSync7(dir, { recursive: true });
|
|
46479
48148
|
const written = [];
|
|
46480
48149
|
const kept = [];
|
|
46481
48150
|
for (const s of specs) {
|
|
46482
|
-
const file =
|
|
46483
|
-
if (
|
|
48151
|
+
const file = join19(dir, `${s.id}.json`);
|
|
48152
|
+
if (existsSync15(file) && !flag(ctx, "force")) {
|
|
46484
48153
|
kept.push(s.id);
|
|
46485
48154
|
continue;
|
|
46486
48155
|
}
|
|
46487
|
-
|
|
48156
|
+
writeFileSync9(file, `${JSON.stringify(s, null, 2)}
|
|
46488
48157
|
`);
|
|
46489
48158
|
written.push(file);
|
|
46490
48159
|
}
|
|
@@ -46500,6 +48169,42 @@ async function suggest(ctx) {
|
|
|
46500
48169
|
].filter(Boolean).join("\n")
|
|
46501
48170
|
};
|
|
46502
48171
|
}
|
|
48172
|
+
function summarizeAttempts(spec, attempts) {
|
|
48173
|
+
if (attempts.length === 1) return attempts[0];
|
|
48174
|
+
const passed = attempts.filter((a) => a.result.status === "passed").length;
|
|
48175
|
+
const rep = attempts.find((a) => a.result.status !== "passed") ?? attempts[0];
|
|
48176
|
+
const result = {
|
|
48177
|
+
...rep.result,
|
|
48178
|
+
attempts: attempts.map((a) => ({ status: a.result.status, durationMs: a.result.durationMs })),
|
|
48179
|
+
stability: passed === 0 || passed === attempts.length ? "stable" : "flaky"
|
|
48180
|
+
};
|
|
48181
|
+
if (result.stability === "stable") return { result, findings: rep.findings };
|
|
48182
|
+
const findings = [
|
|
48183
|
+
...rep.findings.map((f) => ({ ...f, confidence: "low" })),
|
|
48184
|
+
makeFinding({
|
|
48185
|
+
ruleId: "journey/flaky",
|
|
48186
|
+
key: spec.id,
|
|
48187
|
+
journey: spec.id,
|
|
48188
|
+
title: `Journey "${spec.name}" passed ${passed} of ${attempts.length} times`,
|
|
48189
|
+
category: "reliability",
|
|
48190
|
+
kind: "needs-review",
|
|
48191
|
+
severity: "moderate",
|
|
48192
|
+
confidence: "high",
|
|
48193
|
+
provenance: "observed",
|
|
48194
|
+
url: rep.result.steps.at(-1)?.url || spec.start,
|
|
48195
|
+
viewport: rep.result.viewport,
|
|
48196
|
+
description: `The same steps gave different outcomes across ${attempts.length} runs (${attempts.map((a) => a.result.status).join(", ")}). Either the product behaves inconsistently (race conditions, timing, data dependence) or the journey depends on timing; the failing run's evidence is attached to the journey.`,
|
|
48197
|
+
impact: "Intermittent failures reach real users and make before/after comparisons unreliable.",
|
|
48198
|
+
recommendation: "Compare the failing attempt with a passing one: look for requests that finish in a different order, elements that render late, or data created by an earlier run. Fix the product if it is a race; otherwise make the journey wait for the right state.",
|
|
48199
|
+
standards: [],
|
|
48200
|
+
evidence: [{ type: "step", snippet: rep.result.steps.find((s) => s.status !== "passed")?.error ?? "" }],
|
|
48201
|
+
reproduction: [
|
|
48202
|
+
`Run the journey ${attempts.length} times: jourilo journeys run ${spec.id} --repeat ${attempts.length}`
|
|
48203
|
+
]
|
|
48204
|
+
})
|
|
48205
|
+
];
|
|
48206
|
+
return { result, findings };
|
|
48207
|
+
}
|
|
46503
48208
|
async function runCmd(ctx) {
|
|
46504
48209
|
const baseArg = str(ctx, "base");
|
|
46505
48210
|
if (!baseArg)
|
|
@@ -46511,7 +48216,7 @@ async function runCmd(ctx) {
|
|
|
46511
48216
|
"No journeys to run.",
|
|
46512
48217
|
"Create some with `jourilo journeys suggest <url>`."
|
|
46513
48218
|
);
|
|
46514
|
-
const isDir =
|
|
48219
|
+
const isDir = existsSync15(baseArg) && !baseArg.includes("://");
|
|
46515
48220
|
const server = isDir ? await serveStatic(resolve8(baseArg)) : void 0;
|
|
46516
48221
|
try {
|
|
46517
48222
|
const base2 = parseTargetUrl(server?.url ?? baseArg);
|
|
@@ -46519,7 +48224,12 @@ async function runCmd(ctx) {
|
|
|
46519
48224
|
const id = newRunId();
|
|
46520
48225
|
const runDir = ctx.ws.createRunDir(id);
|
|
46521
48226
|
const browserName = str(ctx, "browser") ?? "chromium";
|
|
46522
|
-
const
|
|
48227
|
+
const session = await openGuardedBrowser(browserName, flag(ctx, "headed"), {
|
|
48228
|
+
target: hostClass,
|
|
48229
|
+
allowPrivate: flag(ctx, "allow-private")
|
|
48230
|
+
});
|
|
48231
|
+
const browser = session.browser;
|
|
48232
|
+
const browserVersion = browser.version();
|
|
46523
48233
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
46524
48234
|
const journeys2 = [];
|
|
46525
48235
|
let findings = [];
|
|
@@ -46534,16 +48244,29 @@ async function runCmd(ctx) {
|
|
|
46534
48244
|
limitations.push(
|
|
46535
48245
|
"--allow-destructive is ignored on non-local hosts: irreversible and payment actions were blocked."
|
|
46536
48246
|
);
|
|
48247
|
+
const allowOrigins = list(ctx, "allow-origin").map((o) => {
|
|
48248
|
+
const u = parseTargetUrl(o);
|
|
48249
|
+
return u.origin;
|
|
48250
|
+
});
|
|
48251
|
+
const repeat = Math.max(1, Math.min(10, num(ctx, "repeat", 1)));
|
|
46537
48252
|
const saveAuthArg = str(ctx, "save-auth");
|
|
48253
|
+
if (saveAuthArg && repeat > 1)
|
|
48254
|
+
throw new JouriloError("USAGE", "--save-auth cannot be combined with --repeat.");
|
|
46538
48255
|
if (saveAuthArg && specs.length !== 1)
|
|
46539
48256
|
throw new JouriloError("USAGE", "--save-auth needs exactly one journey (the sign-in journey).");
|
|
46540
48257
|
const saveAuth = saveAuthArg ? resolve8(saveAuthArg) : void 0;
|
|
46541
|
-
if (saveAuth)
|
|
48258
|
+
if (saveAuth) {
|
|
46542
48259
|
limitations.push(
|
|
46543
48260
|
`Signed-in state saved to ${displayPath(saveAuth)} (owner-only permissions). Treat it like a password; it is a test-account session.`
|
|
46544
48261
|
);
|
|
48262
|
+
if (wouldBeCommitted(saveAuth)) {
|
|
48263
|
+
const warning = `WARNING: ${displayPath(saveAuth)} is inside a git repository and not ignored, so it could be committed with its session cookies. Add it to .gitignore or save it under .jourilo/.`;
|
|
48264
|
+
limitations.push(warning);
|
|
48265
|
+
ctx.log(warning);
|
|
48266
|
+
}
|
|
48267
|
+
}
|
|
46545
48268
|
if (flag(ctx, "trace")) {
|
|
46546
|
-
|
|
48269
|
+
mkdirSync7(join19(runDir, "traces"), { recursive: true });
|
|
46547
48270
|
limitations.push(
|
|
46548
48271
|
"Playwright traces contain DOM snapshots and typed values: keep them private (they are excluded from git)."
|
|
46549
48272
|
);
|
|
@@ -46553,27 +48276,38 @@ async function runCmd(ctx) {
|
|
|
46553
48276
|
for (const spec of specs) {
|
|
46554
48277
|
for (const vp of viewports) {
|
|
46555
48278
|
ctx.log(`journey ${spec.id}${vp ? ` (${vp})` : ""}`);
|
|
46556
|
-
const
|
|
46557
|
-
|
|
46558
|
-
|
|
46559
|
-
|
|
46560
|
-
|
|
46561
|
-
|
|
46562
|
-
|
|
46563
|
-
|
|
46564
|
-
|
|
46565
|
-
|
|
46566
|
-
|
|
46567
|
-
|
|
46568
|
-
|
|
46569
|
-
|
|
48279
|
+
const attempts = [];
|
|
48280
|
+
for (let k = 0; k < repeat; k++) {
|
|
48281
|
+
const r = await runJourney(browser, spec, {
|
|
48282
|
+
base: base2,
|
|
48283
|
+
runDir,
|
|
48284
|
+
hostClass,
|
|
48285
|
+
policy,
|
|
48286
|
+
data: repeat > 1 ? testData(`${id}${k}`, parseVars(ctx)) : data,
|
|
48287
|
+
trace: flag(ctx, "trace") && k === 0,
|
|
48288
|
+
stepTimeoutMs: num(ctx, "timeout", 10) * 1e3,
|
|
48289
|
+
...vp ? { viewportOverride: vp } : {},
|
|
48290
|
+
...str(ctx, "auth") ? { storageState: str(ctx, "auth") } : {},
|
|
48291
|
+
...saveAuth ? { saveAuth } : {},
|
|
48292
|
+
...allowOrigins.length ? { allowOrigins } : {},
|
|
48293
|
+
log: k === 0 ? ctx.log : void 0
|
|
48294
|
+
});
|
|
48295
|
+
if (repeat > 1) ctx.log(` attempt ${k + 1}/${repeat}: ${r.result.status}`);
|
|
48296
|
+
attempts.push(r);
|
|
48297
|
+
}
|
|
48298
|
+
const { result, findings: jf } = summarizeAttempts(spec, attempts);
|
|
48299
|
+
ctx.log(
|
|
48300
|
+
` \u2192 ${result.status}${result.stability ? ` (${result.stability})` : ""} (${(result.durationMs / 1e3).toFixed(1)} s)`
|
|
48301
|
+
);
|
|
46570
48302
|
journeys2.push(result);
|
|
46571
48303
|
findings.push(...jf);
|
|
46572
48304
|
}
|
|
46573
48305
|
}
|
|
46574
48306
|
} finally {
|
|
46575
|
-
await
|
|
48307
|
+
await session.close();
|
|
46576
48308
|
}
|
|
48309
|
+
const network = session.report();
|
|
48310
|
+
limitations.push(...networkLimitations(network));
|
|
46577
48311
|
findings = mergeFindings(findings);
|
|
46578
48312
|
const run = {
|
|
46579
48313
|
schemaVersion: RUN_SCHEMA_VERSION,
|
|
@@ -46583,7 +48317,7 @@ async function runCmd(ctx) {
|
|
|
46583
48317
|
target: server ? `${displayPath(resolve8(baseArg))} (served at ${server.url})` : base2.toString(),
|
|
46584
48318
|
startedAt,
|
|
46585
48319
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
46586
|
-
browser: { name: browserName, version:
|
|
48320
|
+
browser: { name: browserName, version: browserVersion },
|
|
46587
48321
|
viewports: parseViewports([...new Set(journeys2.map((j) => j.viewport))].join(",") || "desktop"),
|
|
46588
48322
|
settings: {
|
|
46589
48323
|
journeys: specs.map((s) => s.id),
|
|
@@ -46598,7 +48332,8 @@ async function runCmd(ctx) {
|
|
|
46598
48332
|
limitations: [
|
|
46599
48333
|
...limitations,
|
|
46600
48334
|
"Journeys prove that a scripted path works (or not) in one browser; they do not measure how real people choose paths."
|
|
46601
|
-
]
|
|
48335
|
+
],
|
|
48336
|
+
...network.blocked.length || network.escaped.length ? { network } : {}
|
|
46602
48337
|
};
|
|
46603
48338
|
const repo = str(ctx, "repo") ?? (isDir ? resolve8(baseArg) : void 0);
|
|
46604
48339
|
if (repo) locateFindings(run, repo);
|
|
@@ -46634,6 +48369,7 @@ var journeys = {
|
|
|
46634
48369
|
"allow-submit": { type: "boolean" },
|
|
46635
48370
|
"allow-destructive": { type: "boolean" },
|
|
46636
48371
|
"allow-private": { type: "boolean" },
|
|
48372
|
+
"allow-origin": { type: "string", multiple: true },
|
|
46637
48373
|
"fail-on-failure": { type: "boolean" },
|
|
46638
48374
|
browser: { type: "string" },
|
|
46639
48375
|
headed: { type: "boolean" },
|
|
@@ -46642,7 +48378,8 @@ var journeys = {
|
|
|
46642
48378
|
timeout: { type: "string" },
|
|
46643
48379
|
pages: { type: "string" },
|
|
46644
48380
|
force: { type: "boolean" },
|
|
46645
|
-
"save-auth": { type: "string" }
|
|
48381
|
+
"save-auth": { type: "string" },
|
|
48382
|
+
repeat: { type: "string" }
|
|
46646
48383
|
},
|
|
46647
48384
|
details: `Subcommands:
|
|
46648
48385
|
suggest [url|dir|run] Derive journeys from a site (or an existing run) into .jourilo/journeys/*.json
|
|
@@ -46655,13 +48392,18 @@ Run options:
|
|
|
46655
48392
|
--var key=value Test data for {{key}} placeholders (also JOURILO_VAR_KEY env vars)
|
|
46656
48393
|
--allow-submit Allow form submission on non-local hosts (authorized test environments only)
|
|
46657
48394
|
--allow-destructive Allow irreversible/payment actions \u2014 local hosts with sandbox data only
|
|
48395
|
+
--allow-origin ORIGIN Let the page navigate to another origin you are authorized to test (e.g. a test IdP)
|
|
46658
48396
|
--trace Record a Playwright trace per journey (contains typed values; keep private)
|
|
46659
48397
|
--auth FILE Playwright storage state for signed-in journeys
|
|
46660
48398
|
--save-auth FILE After a passing sign-in journey (exactly one), save its session for --auth (test accounts only)
|
|
48399
|
+
--repeat N Run each journey N times (max 10); mixed outcomes are reported as flaky
|
|
46661
48400
|
--fail-on-failure Exit 1 when a journey fails
|
|
46662
48401
|
|
|
46663
48402
|
Safety: payment, purchase, delete and cancel actions are never performed on non-local hosts; form submission
|
|
46664
|
-
on non-local hosts needs --allow-submit.
|
|
48403
|
+
on non-local hosts needs --allow-submit. The policy is checked twice: on the element about to be clicked or
|
|
48404
|
+
pressed (its name, link target, form action and identifiers \u2014 icon-only buttons included), and on every request
|
|
48405
|
+
the page then sends (form posts, DELETE, requests to delete/cancel/payment endpoints, navigation to other
|
|
48406
|
+
origins). Blocked steps are reported, not skipped silently.`,
|
|
46665
48407
|
async run(ctx) {
|
|
46666
48408
|
const sub = ctx.positionals[0] ?? "list";
|
|
46667
48409
|
if (sub === "suggest") return suggest(ctx);
|
|
@@ -46679,8 +48421,8 @@ ${specs.map((s) => ` ${s.id.padEnd(32)} ${s.steps.length} steps, ${s.success.le
|
|
|
46679
48421
|
};
|
|
46680
48422
|
|
|
46681
48423
|
// src/cli/commands/optimize.ts
|
|
46682
|
-
import { existsSync as
|
|
46683
|
-
import { join as
|
|
48424
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync8, writeFileSync as writeFileSync10 } from "node:fs";
|
|
48425
|
+
import { join as join20, resolve as resolve9 } from "node:path";
|
|
46684
48426
|
init_errors();
|
|
46685
48427
|
init_fs_safety();
|
|
46686
48428
|
|
|
@@ -46751,17 +48493,17 @@ function unifiedDiff(file, before, after, context = 3) {
|
|
|
46751
48493
|
}
|
|
46752
48494
|
|
|
46753
48495
|
// src/repair/fixes.ts
|
|
46754
|
-
import { existsSync as
|
|
48496
|
+
import { existsSync as existsSync16, readFileSync as readFileSync13 } from "node:fs";
|
|
46755
48497
|
import { extname as extname3 } from "node:path";
|
|
46756
48498
|
init_fs_safety();
|
|
46757
48499
|
function base(f, title, explanation, expectedEffect) {
|
|
46758
48500
|
return { fingerprint: f.fingerprint, ruleId: f.ruleId, title, explanation, expectedEffect };
|
|
46759
48501
|
}
|
|
46760
48502
|
var isJsx = (file) => /\.(jsx|tsx)$/.test(file);
|
|
46761
|
-
var htmlLang = (f, _root,
|
|
48503
|
+
var htmlLang = (f, _root, read3) => {
|
|
46762
48504
|
const src = f.sources?.find((s) => s.reason.includes("<html>"));
|
|
46763
48505
|
if (!src) return null;
|
|
46764
|
-
const text =
|
|
48506
|
+
const text = read3(src.file);
|
|
46765
48507
|
if (!text) return null;
|
|
46766
48508
|
const path2 = (() => {
|
|
46767
48509
|
try {
|
|
@@ -46787,10 +48529,10 @@ var htmlLang = (f, _root, read2) => {
|
|
|
46787
48529
|
]
|
|
46788
48530
|
};
|
|
46789
48531
|
};
|
|
46790
|
-
var viewportMeta = (f, _root,
|
|
48532
|
+
var viewportMeta = (f, _root, read3) => {
|
|
46791
48533
|
const src = f.sources?.find((s) => /\.html?$/.test(s.file) && s.reason.includes("viewport"));
|
|
46792
48534
|
if (!src) return null;
|
|
46793
|
-
const text =
|
|
48535
|
+
const text = read3(src.file);
|
|
46794
48536
|
if (!text || /name=["']viewport["']/i.test(text)) return null;
|
|
46795
48537
|
const anchor = /<meta\s+charset=[^>]*>/i.test(text) ? /(<meta\s+charset=[^>]*>)(\r?\n?)(\s*)/i : /(<head\b[^>]*>)(\r?\n?)(\s*)/i;
|
|
46796
48538
|
const after = text.replace(
|
|
@@ -46812,7 +48554,7 @@ ${indent}`
|
|
|
46812
48554
|
]
|
|
46813
48555
|
};
|
|
46814
48556
|
};
|
|
46815
|
-
var autocomplete = (f, root,
|
|
48557
|
+
var autocomplete = (f, root, read3) => {
|
|
46816
48558
|
const edits = /* @__PURE__ */ new Map();
|
|
46817
48559
|
const tokens = [];
|
|
46818
48560
|
for (const e of f.evidence) {
|
|
@@ -46824,7 +48566,7 @@ var autocomplete = (f, root, read2) => {
|
|
|
46824
48566
|
const token = suggestAutocomplete(type, name, label === "\u2205" ? null : label, purpose);
|
|
46825
48567
|
if (token.startsWith("on (")) continue;
|
|
46826
48568
|
for (const s of f.sources ?? []) {
|
|
46827
|
-
const cur = edits.get(s.file)?.after ??
|
|
48569
|
+
const cur = edits.get(s.file)?.after ?? read3(s.file);
|
|
46828
48570
|
if (!cur) continue;
|
|
46829
48571
|
const attr = isJsx(s.file) ? "autoComplete" : "autocomplete";
|
|
46830
48572
|
const re = new RegExp(
|
|
@@ -46832,7 +48574,7 @@ var autocomplete = (f, root, read2) => {
|
|
|
46832
48574
|
);
|
|
46833
48575
|
const next = cur.replace(re, `$1 ${attr}="${token}"$2`);
|
|
46834
48576
|
if (next !== cur) {
|
|
46835
|
-
edits.set(s.file, { before: edits.get(s.file)?.before ??
|
|
48577
|
+
edits.set(s.file, { before: edits.get(s.file)?.before ?? read3(s.file), after: next });
|
|
46836
48578
|
tokens.push(`${name} \u2192 ${token}`);
|
|
46837
48579
|
break;
|
|
46838
48580
|
}
|
|
@@ -46853,10 +48595,10 @@ var autocomplete = (f, root, read2) => {
|
|
|
46853
48595
|
]
|
|
46854
48596
|
};
|
|
46855
48597
|
};
|
|
46856
|
-
var focusVisible = (f, _root,
|
|
48598
|
+
var focusVisible = (f, _root, read3) => {
|
|
46857
48599
|
const src = f.sources?.find((s) => /\.(css|scss|less)$/.test(s.file) && s.reason.includes("focus outline"));
|
|
46858
48600
|
if (!src) return null;
|
|
46859
|
-
const text =
|
|
48601
|
+
const text = read3(src.file);
|
|
46860
48602
|
if (!text) return null;
|
|
46861
48603
|
const lines = text.split("\n");
|
|
46862
48604
|
let start = src.line - 1;
|
|
@@ -46886,10 +48628,10 @@ ${sels.join(",\n")} {
|
|
|
46886
48628
|
]
|
|
46887
48629
|
};
|
|
46888
48630
|
};
|
|
46889
|
-
var pasteAllowed = (f, _root,
|
|
48631
|
+
var pasteAllowed = (f, _root, read3) => {
|
|
46890
48632
|
const src = f.sources?.find((s) => s.reason.includes("paste"));
|
|
46891
48633
|
if (!src) return null;
|
|
46892
|
-
const text =
|
|
48634
|
+
const text = read3(src.file);
|
|
46893
48635
|
if (!text) return null;
|
|
46894
48636
|
const after = text.replace(/(<input\b[^>]*?)\s+on[pP]aste=(?:"[^"]*"|'[^']*'|\{[^}]*\})([^>]*>)/g, "$1$2");
|
|
46895
48637
|
if (after === text) return null;
|
|
@@ -46907,7 +48649,7 @@ var pasteAllowed = (f, _root, read2) => {
|
|
|
46907
48649
|
};
|
|
46908
48650
|
};
|
|
46909
48651
|
var escRe = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
46910
|
-
var imageDimensions = (f, _root,
|
|
48652
|
+
var imageDimensions = (f, _root, read3) => {
|
|
46911
48653
|
const edits = /* @__PURE__ */ new Map();
|
|
46912
48654
|
const done = [];
|
|
46913
48655
|
const notes = [];
|
|
@@ -46917,7 +48659,7 @@ var imageDimensions = (f, _root, read2) => {
|
|
|
46917
48659
|
const h = Number(e.data?.naturalHeight);
|
|
46918
48660
|
if (!src || !(w > 0 && h > 0)) continue;
|
|
46919
48661
|
for (const s of f.sources ?? []) {
|
|
46920
|
-
const cur = edits.get(s.file)?.after ??
|
|
48662
|
+
const cur = edits.get(s.file)?.after ?? read3(s.file);
|
|
46921
48663
|
if (!cur) continue;
|
|
46922
48664
|
const re = new RegExp(
|
|
46923
48665
|
`<img\\b(?=[^>]*\\bsrc=["'{]?["']?${escRe(src)}["'])(?![^>]*\\bwidth=)(?![^>]*\\bheight=)[^>]*?(\\s*/?>)`
|
|
@@ -46935,7 +48677,7 @@ var imageDimensions = (f, _root, read2) => {
|
|
|
46935
48677
|
}
|
|
46936
48678
|
tag = tag.replace(/(\s*\/?>)$/, ` width="${w}" height="${h}"$1`);
|
|
46937
48679
|
edits.set(s.file, {
|
|
46938
|
-
before: edits.get(s.file)?.before ??
|
|
48680
|
+
before: edits.get(s.file)?.before ?? read3(s.file),
|
|
46939
48681
|
after: cur.replace(m[0], tag)
|
|
46940
48682
|
});
|
|
46941
48683
|
done.push(`${src} \u2192 ${w}\xD7${h}`);
|
|
@@ -46957,14 +48699,14 @@ var imageDimensions = (f, _root, read2) => {
|
|
|
46957
48699
|
]
|
|
46958
48700
|
};
|
|
46959
48701
|
};
|
|
46960
|
-
var placeholderLabel = (f, _root,
|
|
48702
|
+
var placeholderLabel = (f, _root, read3) => {
|
|
46961
48703
|
const edits = /* @__PURE__ */ new Map();
|
|
46962
48704
|
const done = [];
|
|
46963
48705
|
for (const e of f.evidence) {
|
|
46964
48706
|
const name = e.snippet?.match(/name=(\S+)/)?.[1];
|
|
46965
48707
|
if (!name || name === "\u2205") continue;
|
|
46966
48708
|
for (const s of f.sources ?? []) {
|
|
46967
|
-
const cur = edits.get(s.file)?.after ??
|
|
48709
|
+
const cur = edits.get(s.file)?.after ?? read3(s.file);
|
|
46968
48710
|
if (!cur) continue;
|
|
46969
48711
|
const jsx = isJsx(s.file);
|
|
46970
48712
|
const re = new RegExp(`<(input|textarea|select)\\b(?=[^>]*\\bname=["']${escRe(name)}["'])[^>]*>`);
|
|
@@ -46982,7 +48724,7 @@ var placeholderLabel = (f, _root, read2) => {
|
|
|
46982
48724
|
}
|
|
46983
48725
|
const label = `<label ${jsx ? "htmlFor" : "for"}="${id}">${placeholder}</label> `;
|
|
46984
48726
|
edits.set(s.file, {
|
|
46985
|
-
before: edits.get(s.file)?.before ??
|
|
48727
|
+
before: edits.get(s.file)?.before ?? read3(s.file),
|
|
46986
48728
|
after: cur.replace(tag, `${label}${newTag}`)
|
|
46987
48729
|
});
|
|
46988
48730
|
done.push(`${name} \u2192 "${placeholder}"`);
|
|
@@ -47012,15 +48754,28 @@ var FIXERS = {
|
|
|
47012
48754
|
"jourilo/unsized-images": imageDimensions,
|
|
47013
48755
|
"jourilo/placeholder-label": placeholderLabel
|
|
47014
48756
|
};
|
|
48757
|
+
function utf8OrNull(buf) {
|
|
48758
|
+
const text = buf.toString("utf8");
|
|
48759
|
+
return Buffer.from(text, "utf8").equals(buf) ? text : null;
|
|
48760
|
+
}
|
|
48761
|
+
function composeEdits(plans) {
|
|
48762
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
48763
|
+
for (const p of plans)
|
|
48764
|
+
for (const e of p.edits) {
|
|
48765
|
+
const prev = byFile.get(e.file);
|
|
48766
|
+
byFile.set(e.file, { file: e.file, before: prev?.before ?? e.before, after: e.after });
|
|
48767
|
+
}
|
|
48768
|
+
return [...byFile.values()];
|
|
48769
|
+
}
|
|
47015
48770
|
function planFixes(findings, root) {
|
|
47016
48771
|
const cache = /* @__PURE__ */ new Map();
|
|
47017
|
-
const
|
|
48772
|
+
const read3 = (file) => {
|
|
47018
48773
|
if (!cache.has(file)) {
|
|
47019
48774
|
try {
|
|
47020
48775
|
const abs = resolveInside(root, file);
|
|
47021
48776
|
cache.set(
|
|
47022
48777
|
file,
|
|
47023
|
-
|
|
48778
|
+
existsSync16(abs) && [
|
|
47024
48779
|
".html",
|
|
47025
48780
|
".htm",
|
|
47026
48781
|
".css",
|
|
@@ -47034,7 +48789,7 @@ function planFixes(findings, root) {
|
|
|
47034
48789
|
".svelte",
|
|
47035
48790
|
".astro",
|
|
47036
48791
|
".php"
|
|
47037
|
-
].includes(extname3(abs).toLowerCase()) ?
|
|
48792
|
+
].includes(extname3(abs).toLowerCase()) ? utf8OrNull(readFileSync13(abs)) : null
|
|
47038
48793
|
);
|
|
47039
48794
|
} catch {
|
|
47040
48795
|
cache.set(file, null);
|
|
@@ -47047,10 +48802,10 @@ function planFixes(findings, root) {
|
|
|
47047
48802
|
for (const f of findings) {
|
|
47048
48803
|
const fixer = FIXERS[f.ruleId];
|
|
47049
48804
|
if (!fixer || !f.sources?.length) continue;
|
|
47050
|
-
const plan = fixer(f, root, (file) => touched.get(file) ??
|
|
48805
|
+
const plan = fixer(f, root, (file) => touched.get(file) ?? read3(file));
|
|
47051
48806
|
if (!plan) continue;
|
|
47052
48807
|
for (const e of plan.edits) {
|
|
47053
|
-
e.before = touched.get(e.file) ??
|
|
48808
|
+
e.before = touched.get(e.file) ?? read3(e.file) ?? e.before;
|
|
47054
48809
|
touched.set(e.file, e.after);
|
|
47055
48810
|
}
|
|
47056
48811
|
if (plans.some(
|
|
@@ -47062,119 +48817,21 @@ function planFixes(findings, root) {
|
|
|
47062
48817
|
return plans;
|
|
47063
48818
|
}
|
|
47064
48819
|
|
|
47065
|
-
// src/
|
|
47066
|
-
|
|
47067
|
-
|
|
47068
|
-
|
|
47069
|
-
|
|
47070
|
-
|
|
47071
|
-
|
|
47072
|
-
const r = spawnSync2("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
47073
|
-
return { ok: r.status === 0, out: (r.stdout ?? "").trim(), err: (r.stderr ?? "").trim() };
|
|
47074
|
-
}
|
|
47075
|
-
function isGitRepo(root) {
|
|
47076
|
-
return git(root, ["rev-parse", "--is-inside-work-tree"]).out === "true";
|
|
47077
|
-
}
|
|
47078
|
-
function untrackedFiles(root) {
|
|
47079
|
-
return git(root, ["ls-files", "--others", "--exclude-standard"]).out.split("\n").filter(Boolean);
|
|
47080
|
-
}
|
|
47081
|
-
function createCheckpoint(root, wsRoot, note = "") {
|
|
47082
|
-
if (!isGitRepo(root))
|
|
47083
|
-
throw new JouriloError(
|
|
47084
|
-
"INVALID_INPUT",
|
|
47085
|
-
`${root} is not a git repository; checkpoints need git.`,
|
|
47086
|
-
"Run `git init` and commit, or keep your own backup before applying fixes."
|
|
47087
|
-
);
|
|
47088
|
-
const head = git(root, ["rev-parse", "HEAD"]);
|
|
47089
|
-
if (!head.ok)
|
|
47090
|
-
throw new JouriloError(
|
|
47091
|
-
"INVALID_INPUT",
|
|
47092
|
-
"The repository has no commits yet; commit once before using checkpoints."
|
|
47093
|
-
);
|
|
47094
|
-
const stash = git(root, ["stash", "create", `jourilo checkpoint ${note}`.trim()]);
|
|
47095
|
-
const cp = {
|
|
47096
|
-
id: newRunId(),
|
|
47097
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
47098
|
-
head: head.out,
|
|
47099
|
-
snapshot: stash.out || head.out,
|
|
47100
|
-
untracked: untrackedFiles(root),
|
|
47101
|
-
note
|
|
47102
|
-
};
|
|
47103
|
-
git(root, ["update-ref", `refs/jourilo/checkpoints/${cp.id}`, cp.snapshot]);
|
|
47104
|
-
const dir = join16(wsRoot, "checkpoints");
|
|
47105
|
-
mkdirSync6(dir, { recursive: true });
|
|
47106
|
-
writeFileSync9(join16(dir, `${cp.id}.json`), `${JSON.stringify(cp, null, 2)}
|
|
47107
|
-
`);
|
|
47108
|
-
return cp;
|
|
47109
|
-
}
|
|
47110
|
-
function loadCheckpoint(wsRoot, id) {
|
|
47111
|
-
const dir = join16(wsRoot, "checkpoints");
|
|
47112
|
-
const pick = id === "latest" ? existsSync14(dir) ? readdirSync8(dir).filter((f) => f.endsWith(".json")).sort().pop()?.replace(/\.json$/, "") : void 0 : id;
|
|
47113
|
-
if (!pick || !/^[\w-]+$/.test(pick) || !existsSync14(join16(dir, `${pick}.json`)))
|
|
47114
|
-
throw new JouriloError("NOT_FOUND", `Checkpoint not found: ${id}`);
|
|
47115
|
-
return JSON.parse(readFileSync11(join16(dir, `${pick}.json`), "utf8"));
|
|
47116
|
-
}
|
|
47117
|
-
function diffSince(root, cp) {
|
|
47118
|
-
const stat = git(root, ["diff", "--stat", cp.snapshot]).out;
|
|
47119
|
-
const patch = git(root, ["diff", cp.snapshot]).out;
|
|
47120
|
-
const newFiles = untrackedFiles(root).filter((f) => !cp.untracked.includes(f));
|
|
47121
|
-
return { stat, patch, newFiles };
|
|
47122
|
-
}
|
|
47123
|
-
function backupDir(wsRoot, cp) {
|
|
47124
|
-
return join16(wsRoot, "checkpoints", cp.id);
|
|
47125
|
-
}
|
|
47126
|
-
function backupFiles(root, wsRoot, cp, files) {
|
|
47127
|
-
for (const f of files) {
|
|
47128
|
-
const src = resolveInside(root, f);
|
|
47129
|
-
if (!existsSync14(src)) continue;
|
|
47130
|
-
const dest = resolveInside(backupDir(wsRoot, cp), f);
|
|
47131
|
-
mkdirSync6(dirname6(dest), { recursive: true });
|
|
47132
|
-
writeFileSync9(dest, readFileSync11(src));
|
|
48820
|
+
// src/report/compare.ts
|
|
48821
|
+
var RESOLUTION = { LCP: 150, FCP: 150, TBT: 100, CLS: 0.02, Transfer: 0 };
|
|
48822
|
+
function metricVerdict(name, a, b) {
|
|
48823
|
+
const delta = b.value - a.value;
|
|
48824
|
+
if (name === "Transfer") {
|
|
48825
|
+
if (Math.abs(delta) <= Math.max(2048, a.value * 0.02)) return "noise";
|
|
48826
|
+
return delta < 0 ? "improved" : "regressed";
|
|
47133
48827
|
}
|
|
47134
|
-
|
|
47135
|
-
|
|
47136
|
-
|
|
47137
|
-
|
|
47138
|
-
maxBuffer: 64 * 1024 * 1024
|
|
47139
|
-
});
|
|
47140
|
-
return r.status === 0 ? r.stdout : null;
|
|
47141
|
-
}
|
|
47142
|
-
function rollback(root, cp, wsRoot) {
|
|
47143
|
-
const changed = git(root, ["diff", "--name-only", cp.snapshot]).out.split("\n").filter(Boolean);
|
|
47144
|
-
const restored = [];
|
|
47145
|
-
const notRestorable = [];
|
|
47146
|
-
for (const f of changed) {
|
|
47147
|
-
const target = resolveInside(root, f);
|
|
47148
|
-
const saved = wsRoot ? resolveInside(backupDir(wsRoot, cp), f) : null;
|
|
47149
|
-
if (saved && existsSync14(saved)) {
|
|
47150
|
-
writeFileSync9(target, readFileSync11(saved));
|
|
47151
|
-
restored.push(f);
|
|
47152
|
-
continue;
|
|
47153
|
-
}
|
|
47154
|
-
const content = blob(root, cp.snapshot, f);
|
|
47155
|
-
if (!content) {
|
|
47156
|
-
notRestorable.push(f);
|
|
47157
|
-
continue;
|
|
47158
|
-
}
|
|
47159
|
-
let out = content;
|
|
47160
|
-
const current = existsSync14(target) ? readFileSync11(target) : null;
|
|
47161
|
-
if (current?.includes("\r\n") && !content.includes("\r\n"))
|
|
47162
|
-
out = Buffer.from(content.toString("utf8").replace(/\n/g, "\r\n"), "utf8");
|
|
47163
|
-
mkdirSync6(dirname6(target), { recursive: true });
|
|
47164
|
-
writeFileSync9(target, out);
|
|
47165
|
-
restored.push(f);
|
|
48828
|
+
if (a.range && b.range) {
|
|
48829
|
+
const overlap = a.range[0] <= b.range[1] && b.range[0] <= a.range[1];
|
|
48830
|
+
if (overlap || Math.abs(delta) < (RESOLUTION[name] ?? 0) / 2) return "noise";
|
|
48831
|
+
return delta < 0 ? "improved" : "regressed";
|
|
47166
48832
|
}
|
|
47167
|
-
return
|
|
47168
|
-
restored,
|
|
47169
|
-
// Files that did not exist at checkpoint time are listed, never deleted.
|
|
47170
|
-
leftUntracked: [
|
|
47171
|
-
...notRestorable,
|
|
47172
|
-
...untrackedFiles(root).filter((f) => !cp.untracked.includes(f) && !notRestorable.includes(f))
|
|
47173
|
-
]
|
|
47174
|
-
};
|
|
48833
|
+
return Math.abs(delta) < (RESOLUTION[name] ?? 0) ? "noise" : "unconfirmed";
|
|
47175
48834
|
}
|
|
47176
|
-
|
|
47177
|
-
// src/report/compare.ts
|
|
47178
48835
|
function pathKey(u) {
|
|
47179
48836
|
try {
|
|
47180
48837
|
const x = new URL(u);
|
|
@@ -47200,7 +48857,15 @@ function compareRuns(base2, head) {
|
|
|
47200
48857
|
const b = hp.metrics.find((m) => m.name === name);
|
|
47201
48858
|
if (!a || !b) continue;
|
|
47202
48859
|
const delta = Math.round((b.value - a.value) * 1e3) / 1e3;
|
|
47203
|
-
metrics.push({
|
|
48860
|
+
metrics.push({
|
|
48861
|
+
url: hp.url,
|
|
48862
|
+
viewport: hp.viewport,
|
|
48863
|
+
name,
|
|
48864
|
+
before: a.value,
|
|
48865
|
+
after: b.value,
|
|
48866
|
+
delta,
|
|
48867
|
+
verdict: metricVerdict(name, a, b)
|
|
48868
|
+
});
|
|
47204
48869
|
}
|
|
47205
48870
|
}
|
|
47206
48871
|
const regressionFree = !introduced.some(
|
|
@@ -47212,7 +48877,7 @@ function compareRuns(base2, head) {
|
|
|
47212
48877
|
// src/cli/commands/optimize.ts
|
|
47213
48878
|
var optimize = {
|
|
47214
48879
|
name: "optimize",
|
|
47215
|
-
summary: "Plan (and with --apply, make) minimal source fixes for located findings; shows diffs,
|
|
48880
|
+
summary: "Plan (and with --apply, make) minimal source fixes for located findings; shows diffs, writes one reversible change.",
|
|
47216
48881
|
usage: "jourilo optimize [run=latest] --repo <path> [--rule id ...] [--apply]",
|
|
47217
48882
|
options: {
|
|
47218
48883
|
repo: { type: "string" },
|
|
@@ -47221,13 +48886,16 @@ var optimize = {
|
|
|
47221
48886
|
apply: { type: "boolean" }
|
|
47222
48887
|
},
|
|
47223
48888
|
details: `Deterministic fixers (others are reported with source candidates for a person or agent to fix):
|
|
47224
|
-
axe/html-has-lang, jourilo/missing-viewport-meta, jourilo/input-purpose, jourilo/focus-visible, jourilo/paste-blocked
|
|
47225
|
-
|
|
48889
|
+
axe/html-has-lang, jourilo/missing-viewport-meta, jourilo/input-purpose, jourilo/focus-visible, jourilo/paste-blocked,
|
|
48890
|
+
jourilo/unsized-images, jourilo/placeholder-label
|
|
48891
|
+
Without --apply nothing is written. With --apply, all files are written as one change: each file must be unchanged
|
|
48892
|
+
since the plan, writes are atomic, an interrupted apply is undone, and \`jourilo rollback <id>\` restores the files
|
|
48893
|
+
(git is not required).
|
|
47226
48894
|
Afterwards run \`jourilo verify <run>\` to repeat the audit and compare.`,
|
|
47227
48895
|
async run(ctx) {
|
|
47228
48896
|
const { run, dir } = ctx.ws.loadRun(ctx.positionals[0] ?? "latest");
|
|
47229
48897
|
const repo = resolve9(str(ctx, "repo") ?? run.settings.repo ?? ".");
|
|
47230
|
-
if (!
|
|
48898
|
+
if (!existsSync17(repo)) throw new JouriloError("NOT_FOUND", `Repository not found: ${repo}`);
|
|
47231
48899
|
if (run.findings.every((f) => !f.sources)) locateFindings(run, repo);
|
|
47232
48900
|
const rules = list(ctx, "rule");
|
|
47233
48901
|
const only = list(ctx, "finding");
|
|
@@ -47254,23 +48922,29 @@ Afterwards run \`jourilo verify <run>\` to repeat the audit and compare.`,
|
|
|
47254
48922
|
);
|
|
47255
48923
|
let checkpoint2 = null;
|
|
47256
48924
|
if (flag(ctx, "apply") && plans.length) {
|
|
47257
|
-
const
|
|
47258
|
-
|
|
47259
|
-
|
|
47260
|
-
|
|
47261
|
-
|
|
47262
|
-
|
|
47263
|
-
|
|
47264
|
-
|
|
47265
|
-
|
|
47266
|
-
|
|
48925
|
+
const edits = composeEdits(plans);
|
|
48926
|
+
const tx = applyTransaction(
|
|
48927
|
+
repo,
|
|
48928
|
+
ctx.ws.root,
|
|
48929
|
+
edits.map((e) => ({
|
|
48930
|
+
file: e.file,
|
|
48931
|
+
before: Buffer.from(e.before, "utf8"),
|
|
48932
|
+
after: Buffer.from(e.after, "utf8")
|
|
48933
|
+
})),
|
|
48934
|
+
{
|
|
48935
|
+
note: `optimize run ${run.id}`
|
|
48936
|
+
}
|
|
48937
|
+
);
|
|
48938
|
+
checkpoint2 = tx.id;
|
|
48939
|
+
const outDir = join20(ctx.ws.root, "fixes");
|
|
48940
|
+
mkdirSync8(outDir, { recursive: true });
|
|
47267
48941
|
writeFileSync10(
|
|
47268
|
-
|
|
48942
|
+
join20(outDir, `${run.id}.json`),
|
|
47269
48943
|
`${JSON.stringify({ run: run.id, checkpoint: checkpoint2, applied: plans.map((p) => ({ fingerprint: p.fingerprint, ruleId: p.ruleId, title: p.title, files: p.edits.map((e) => e.file) })) }, null, 2)}
|
|
47270
48944
|
`
|
|
47271
48945
|
);
|
|
47272
48946
|
lines.push(
|
|
47273
|
-
`Applied ${plans.length} fix(es) to ${
|
|
48947
|
+
`Applied ${plans.length} fix(es) to ${edits.length} file(s) as change ${checkpoint2} (undo: jourilo rollback ${checkpoint2} --yes).`,
|
|
47274
48948
|
`Next: jourilo verify ${run.id}`
|
|
47275
48949
|
);
|
|
47276
48950
|
} else if (plans.length) {
|
|
@@ -47278,7 +48952,7 @@ Afterwards run \`jourilo verify <run>\` to repeat the audit and compare.`,
|
|
|
47278
48952
|
}
|
|
47279
48953
|
lines.push(
|
|
47280
48954
|
"",
|
|
47281
|
-
`${manual.length} other defect(s) need a person or agent; source candidates are in the report (${
|
|
48955
|
+
`${manual.length} other defect(s) need a person or agent; source candidates are in the report (${join20(dir, "report.html")}).`
|
|
47282
48956
|
);
|
|
47283
48957
|
return {
|
|
47284
48958
|
data: {
|
|
@@ -47299,6 +48973,21 @@ Afterwards run \`jourilo verify <run>\` to repeat the audit and compare.`,
|
|
|
47299
48973
|
};
|
|
47300
48974
|
}
|
|
47301
48975
|
};
|
|
48976
|
+
function metricSummary(metrics) {
|
|
48977
|
+
if (!metrics.length) return [];
|
|
48978
|
+
const n = (v) => metrics.filter((m) => m.verdict === v);
|
|
48979
|
+
const lines = [
|
|
48980
|
+
` lab metrics: ${n("improved").length} improved \xB7 ${n("regressed").length} regressed \xB7 ${n("noise").length} within noise \xB7 ${n("unconfirmed").length} unconfirmed`,
|
|
48981
|
+
...[...n("improved"), ...n("regressed")].map(
|
|
48982
|
+
(m) => ` ${m.verdict === "improved" ? "\u2193" : "\u2191"} ${m.name} ${new URL(m.url).pathname} (${m.viewport}): ${m.before} \u2192 ${m.after}`
|
|
48983
|
+
)
|
|
48984
|
+
];
|
|
48985
|
+
if (n("unconfirmed").length)
|
|
48986
|
+
lines.push(
|
|
48987
|
+
" Unconfirmed changes come from single visits; repeat with `jourilo audit \u2026 --runs 5` to confirm."
|
|
48988
|
+
);
|
|
48989
|
+
return lines;
|
|
48990
|
+
}
|
|
47302
48991
|
var verify = {
|
|
47303
48992
|
name: "verify",
|
|
47304
48993
|
summary: "Repeat a run with the same scope (pages, viewports) after a change and compare: fixed, new, persisting, metric deltas.",
|
|
@@ -47316,7 +49005,7 @@ var verify = {
|
|
|
47316
49005
|
"INVALID_INPUT",
|
|
47317
49006
|
`Run ${base2.id} has no recorded scope (created by an older version or by journeys).`
|
|
47318
49007
|
);
|
|
47319
|
-
const servedDir = (str(ctx, "target") &&
|
|
49008
|
+
const servedDir = (str(ctx, "target") && existsSync17(str(ctx, "target")) ? resolve9(str(ctx, "target")) : void 0) ?? base2.settings.servedDir;
|
|
47320
49009
|
const server = servedDir ? await serveStatic(servedDir) : void 0;
|
|
47321
49010
|
try {
|
|
47322
49011
|
const origin = server?.url ?? new URL(str(ctx, "target") ?? scope.urls[0]).origin;
|
|
@@ -47337,6 +49026,8 @@ var verify = {
|
|
|
47337
49026
|
allowPrivate: base2.settings.hostClass === "private",
|
|
47338
49027
|
keyboard: base2.settings.keyboard !== false,
|
|
47339
49028
|
throttle: base2.settings.throttle === true,
|
|
49029
|
+
// Same number of visits as the baseline, so medians are compared with medians.
|
|
49030
|
+
runs: typeof base2.settings.runs === "number" ? base2.settings.runs : 1,
|
|
47340
49031
|
timeoutMs: 3e4,
|
|
47341
49032
|
mask: [],
|
|
47342
49033
|
mode: base2.mode,
|
|
@@ -47372,6 +49063,7 @@ var verify = {
|
|
|
47372
49063
|
` fixed ${cmp.fixed.length} \xB7 new ${cmp.introduced.length} \xB7 still present ${cmp.persisting.length}`,
|
|
47373
49064
|
...cmp.fixed.map((f) => ` \u2713 fixed ${f.title} (${new URL(f.url).pathname})`),
|
|
47374
49065
|
...cmp.introduced.map((f) => ` \u2717 new ${f.severity} ${f.title} (${new URL(f.url).pathname})`),
|
|
49066
|
+
...metricSummary(cmp.metrics),
|
|
47375
49067
|
cmp.regressionFree ? " No new serious/critical defects." : " \u26A0 Regression: new serious/critical defects.",
|
|
47376
49068
|
` Report: ${reports.html}`
|
|
47377
49069
|
].join("\n"),
|
|
@@ -47391,6 +49083,11 @@ var checkpoint = {
|
|
|
47391
49083
|
const repo = resolve9(str(ctx, "repo") ?? ".");
|
|
47392
49084
|
if (ctx.positionals[0] === "diff") {
|
|
47393
49085
|
const cp2 = loadCheckpoint(ctx.ws.root, ctx.positionals[1] ?? "latest");
|
|
49086
|
+
if (isTransaction(cp2))
|
|
49087
|
+
return {
|
|
49088
|
+
data: { transaction: cp2, files: previewRollback(cp2) },
|
|
49089
|
+
text: previewRollback(cp2).map((f) => `${f.action.padEnd(9)} ${f.file}${f.reason ? ` (${f.reason})` : ""}`).join("\n")
|
|
49090
|
+
};
|
|
47394
49091
|
const d = diffSince(repo, cp2);
|
|
47395
49092
|
return {
|
|
47396
49093
|
data: { checkpoint: cp2, ...d },
|
|
@@ -47409,26 +49106,78 @@ ${d.patch}`
|
|
|
47409
49106
|
};
|
|
47410
49107
|
var rollbackCmd = {
|
|
47411
49108
|
name: "rollback",
|
|
47412
|
-
summary: "
|
|
47413
|
-
usage: "jourilo rollback <
|
|
47414
|
-
options: {
|
|
49109
|
+
summary: "Undo an applied fix (or restore a checkpoint) without overwriting later edits; requires --yes. The rollback can itself be undone.",
|
|
49110
|
+
usage: "jourilo rollback <id|latest> --yes [--repo .] [--file path ...] [--force]",
|
|
49111
|
+
options: {
|
|
49112
|
+
repo: { type: "string" },
|
|
49113
|
+
yes: { type: "boolean" },
|
|
49114
|
+
file: { type: "string", multiple: true },
|
|
49115
|
+
force: { type: "boolean" }
|
|
49116
|
+
},
|
|
49117
|
+
details: `For a change made by \`optimize --apply\` (or a previous rollback), a file is restored only while it still
|
|
49118
|
+
holds exactly what Jourilo wrote. A file edited afterwards is reported as a conflict and left alone; --force restores
|
|
49119
|
+
it anyway after saving the current bytes under .jourilo/checkpoints/<id>/conflicts/.
|
|
49120
|
+
For a checkpoint made with \`jourilo checkpoint\`, every tracked file changed since then is restored (everything you
|
|
49121
|
+
or an agent changed in the meantime); the current bytes are kept, and the printed id undoes the rollback.
|
|
49122
|
+
Untracked files created after a checkpoint are listed, never deleted.`,
|
|
47415
49123
|
async run(ctx) {
|
|
49124
|
+
const rec = loadCheckpoint(ctx.ws.root, ctx.positionals[0] ?? "latest");
|
|
49125
|
+
const files = list(ctx, "file");
|
|
49126
|
+
if (isTransaction(rec)) {
|
|
49127
|
+
const preview = previewRollback(rec).filter((f) => !files.length || files.includes(f.file));
|
|
49128
|
+
if (!flag(ctx, "yes"))
|
|
49129
|
+
return {
|
|
49130
|
+
data: { transaction: rec.id, preview },
|
|
49131
|
+
text: [
|
|
49132
|
+
`Change ${rec.id} (${rec.note || "no note"}), ${rec.state}:`,
|
|
49133
|
+
...preview.map((f) => ` ${f.action.padEnd(9)} ${f.file}${f.reason ? ` \u2014 ${f.reason}` : ""}`),
|
|
49134
|
+
preview.some((f) => f.action === "conflict") ? "Conflicting files are left alone unless you add --force (their current bytes are saved first)." : "",
|
|
49135
|
+
"Re-run with --yes to roll back."
|
|
49136
|
+
].filter(Boolean).join("\n")
|
|
49137
|
+
};
|
|
49138
|
+
const r2 = rollbackTransaction(ctx.ws.root, rec, {
|
|
49139
|
+
force: flag(ctx, "force"),
|
|
49140
|
+
...files.length ? { files } : {}
|
|
49141
|
+
});
|
|
49142
|
+
const conflicted = r2.conflicts.filter((c) => !c.savedCopy && !flag(ctx, "force"));
|
|
49143
|
+
return {
|
|
49144
|
+
data: { transaction: rec.id, ...r2 },
|
|
49145
|
+
text: [
|
|
49146
|
+
`Restored ${r2.restored.length} file(s) from change ${rec.id}.`,
|
|
49147
|
+
...r2.alreadyRestored.map((f) => ` unchanged ${f} (already original)`),
|
|
49148
|
+
...r2.conflicts.map(
|
|
49149
|
+
(c) => ` ${c.savedCopy ? "forced " : "conflict "} ${c.file} \u2014 ${c.reason}${c.savedCopy ? ` (your version saved to ${displayPath(c.savedCopy)})` : ""}`
|
|
49150
|
+
),
|
|
49151
|
+
conflicted.length ? "Conflicting files were not touched. Use --force to restore them anyway." : ""
|
|
49152
|
+
].filter(Boolean).join("\n"),
|
|
49153
|
+
exitCode: conflicted.length ? 1 : 0
|
|
49154
|
+
};
|
|
49155
|
+
}
|
|
49156
|
+
const cp = rec;
|
|
47416
49157
|
const repo = resolve9(str(ctx, "repo") ?? ".");
|
|
47417
|
-
const cp = loadCheckpoint(ctx.ws.root, ctx.positionals[0] ?? "latest");
|
|
47418
49158
|
const d = diffSince(repo, cp);
|
|
47419
49159
|
if (!flag(ctx, "yes"))
|
|
47420
49160
|
return {
|
|
47421
49161
|
data: { checkpoint: cp, wouldRestore: d.stat, newFiles: d.newFiles },
|
|
47422
|
-
text: `Would restore:
|
|
49162
|
+
text: `Would restore (every change since the checkpoint, including your own edits):
|
|
47423
49163
|
${d.stat || "(nothing)"}
|
|
47424
|
-
Re-run with --yes to roll back.`,
|
|
49164
|
+
The current versions are kept and the rollback can be undone. Re-run with --yes to roll back.`,
|
|
47425
49165
|
exitCode: 0
|
|
47426
49166
|
};
|
|
47427
|
-
const r =
|
|
49167
|
+
const r = rollbackCheckpoint(repo, cp, ctx.ws.root, files.length ? { files } : {});
|
|
49168
|
+
if (r.transaction && !files.length) markRolledBack(ctx.ws.root, cp, r.transaction.id);
|
|
47428
49169
|
return {
|
|
47429
|
-
data:
|
|
47430
|
-
|
|
47431
|
-
|
|
49170
|
+
data: {
|
|
49171
|
+
checkpoint: cp.id,
|
|
49172
|
+
transaction: r.transaction?.id ?? null,
|
|
49173
|
+
restored: r.restored,
|
|
49174
|
+
leftUntracked: r.leftUntracked
|
|
49175
|
+
},
|
|
49176
|
+
text: [
|
|
49177
|
+
`Restored ${r.restored.length} file(s) to checkpoint ${cp.id}.`,
|
|
49178
|
+
r.transaction ? `Undo this rollback with: jourilo rollback ${r.transaction.id} --yes` : "",
|
|
49179
|
+
r.leftUntracked.length ? `Not removed (created after the checkpoint): ${r.leftUntracked.join(", ")}` : ""
|
|
49180
|
+
].filter(Boolean).join("\n")
|
|
47432
49181
|
};
|
|
47433
49182
|
}
|
|
47434
49183
|
};
|
|
@@ -47675,7 +49424,18 @@ function usage() {
|
|
|
47675
49424
|
lines.push("Global options: --json (machine-readable stdout), --workspace DIR (default .jourilo), --quiet");
|
|
47676
49425
|
return lines.join("\n");
|
|
47677
49426
|
}
|
|
49427
|
+
function nodeSupported(version) {
|
|
49428
|
+
const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
|
|
49429
|
+
return major > 20 || major === 20 && minor >= 10;
|
|
49430
|
+
}
|
|
47678
49431
|
async function main(argv) {
|
|
49432
|
+
if (!nodeSupported(process.versions.node)) {
|
|
49433
|
+
process.stderr.write(
|
|
49434
|
+
`Jourilo needs Node.js 20.10 or newer (this is ${process.versions.node}). Install a current LTS from https://nodejs.org.
|
|
49435
|
+
`
|
|
49436
|
+
);
|
|
49437
|
+
return 2;
|
|
49438
|
+
}
|
|
47679
49439
|
const [name, ...rest] = argv;
|
|
47680
49440
|
if (!name || name === "--help" || name === "-h" || name === "help") {
|
|
47681
49441
|
process.stdout.write(`${usage()}
|
|
@@ -47736,7 +49496,8 @@ main(process.argv.slice(2)).then((code) => {
|
|
|
47736
49496
|
process.exitCode = code;
|
|
47737
49497
|
});
|
|
47738
49498
|
export {
|
|
47739
|
-
main
|
|
49499
|
+
main,
|
|
49500
|
+
nodeSupported
|
|
47740
49501
|
};
|
|
47741
49502
|
/*! Bundled license information:
|
|
47742
49503
|
|