@fringetools/cli 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -2
- package/dist/bin.js +1 -1
- package/dist/{chunk-ZTIMW7ON.js → chunk-JJI65ERU.js} +226 -72
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/skill/fringe-help/SKILL.md +1 -0
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@ Prefer a permanent command? `npm install -D @fringetools/cli`, then `npx fringe
|
|
|
23
23
|
|---|---|
|
|
24
24
|
| `fringe check [dir\|file]` | Scan a directory (default `.`) or judge one file (the project root is found by climbing to the nearest `package.json`). Prints findings and the health score. Exit `1` on any `error`. |
|
|
25
25
|
| `fringe scan <url> [--states file] [--stress] [--storage-state auth.json] [--screenshots dir] [--update-baseline] [--json]` | **Runtime check of a live page** (needs `npm i -D playwright-core axe-core`; uses your installed Chrome). Runs axe-core for real contrast, computed names and ARIA relations (`FR-400`; critical/serious = `error`, the rest `review`), then walks the page with Tab and reports keyboard traps (`FR-401`, error) and controls Tab never reaches (review). Accepts `http(s)://` and `file://` URLs. Exit `1` on errors. |
|
|
26
|
+
| `fringe login <url> [--out file]` | **Log in once, by hand, in a real browser window** and save the session (default `.fringe/auth.json`) for `scan --storage-state`. Works with MFA and SSO. The file holds live cookies: keep it out of git. |
|
|
26
27
|
| `fringe rules [dir] [--json]` | **Per-rule health for this repo**: findings still open, accepted into the baseline, and silenced with `fringe-ignore`, plus the ledger trend when `--record` has been used. A rule that is mostly overridden here is flagged: it may not suit the repo, so retune, disable or report it. `fringe/rules.v1`. |
|
|
27
28
|
| `fringe handoff [dir\|file] [--scan url]` | **Work order for another agent or CI.** Prints a self-contained prompt (rules, definition of done, the ordered plan) and saves `.fringe/handoff.json` (`fringe/handoff.v1`). `--json` prints the file; `--out` chooses where. |
|
|
28
29
|
| `fringe verify [--handoff file] [--json]` | **Proof it was done.** Re-checks everything the hand-off covered and reports `complete` / `partial` / `regressed`: errors fixed, remaining, introduced, and how many findings were silenced with `fringe-ignore` since. Exit `0` only when `complete` (`fringe/verify.v1`). |
|
|
@@ -108,9 +109,54 @@ npx @fringetools/cli scan http://localhost:3000 --modes dark,reflow # a sub
|
|
|
108
109
|
|
|
109
110
|
Each mode runs on a fresh page so one mode's emulation never leaks into the next, and only findings the plain page did not already have are kept. Findings are labelled `[dark mode]`, `[reduced motion]`, `[reflow]`. Modes apply to the initial page, not to named states.
|
|
110
111
|
|
|
111
|
-
### Pages behind a login
|
|
112
|
+
### Pages behind a login
|
|
113
|
+
|
|
114
|
+
Most apps hide the interesting pages behind a sign-in, and a scan that gets bounced to the login screen audits the wrong page. Three ways to be logged in; pick the one that fits.
|
|
115
|
+
|
|
116
|
+
**1. Log in once by hand (works with MFA, SSO, captchas).**
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
npx @fringetools/cli login http://localhost:3000/login # a Chrome window opens
|
|
120
|
+
# sign in as you normally would, then press Enter in the terminal (close the window afterwards)
|
|
121
|
+
npx @fringetools/cli scan http://localhost:3000/dashboard --storage-state .fringe/auth.json
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The session (cookies and local storage) is saved to `.fringe/auth.json`. It is a live login: **keep it out of git** (the command warns if `.gitignore` does not list `.fringe/`) and expect it to expire.
|
|
125
|
+
|
|
126
|
+
**2. A scripted login (for CI and agents).** Describe the steps in `fringe.scan.json`; credentials come from environment variables, so none are stored in the file:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"login": {
|
|
131
|
+
"url": "/login",
|
|
132
|
+
"steps": [
|
|
133
|
+
{ "fill": "#email", "value": "${env:FRINGE_EMAIL}" },
|
|
134
|
+
{ "fill": "#password", "value": "${env:FRINGE_PASSWORD}" },
|
|
135
|
+
{ "click": "button:has-text(\"Sign in\")" },
|
|
136
|
+
{ "waitFor": "[data-test=dashboard]" }
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
"states": [ { "name": "settings", "steps": [ { "goto": "/settings" } ] } ]
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Use a **test account**, not a real user. A missing variable stops the scan before any browser opens, naming the variable and never its value; messages show the step with the `${env:...}` placeholder, not the secret. Prefer specific selectors: `text=Sign in` can match the page heading instead of the button.
|
|
144
|
+
|
|
145
|
+
**3. Several roles in one scan.** List sessions; each runs the whole scan (initial page, states, stress modes) and findings are labelled `[as admin]`, `[as member]`:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{ "sessions": [
|
|
149
|
+
{ "name": "member", "login": { "url": "/login", "steps": [ ... ] } },
|
|
150
|
+
{ "name": "admin", "storageState": "admin-auth.json" }
|
|
151
|
+
] }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**What you will see when something is off**
|
|
155
|
+
- `FR-405` "the scan was sent to /login, which looks like a login page": the session is missing or expired. Everything else in that run describes the login screen, not your app, so refresh the session first.
|
|
156
|
+
- `FR-402` "login failed at step N": a selector did not match or the page did not reach the expected state. The step is shown; fix it in `fringe.scan.json`.
|
|
157
|
+
|
|
158
|
+
### Screenshots and a baseline for scan findings
|
|
112
159
|
|
|
113
|
-
- **Login:** save a browser session once, then point the scan at it: `npx playwright-core codegen --save-storage=auth.json http://localhost:3000` (log in by hand in the window that opens, then close it). Use it with `--storage-state auth.json`, or put `"storageState": "auth.json"` at the top of `fringe.scan.json` (relative to that file). Keep the file out of git: it contains session cookies.
|
|
114
160
|
- **Screenshots:** `--screenshots shots/` saves a full-page PNG of the initial page, each named state and each stress mode, so a reviewer can see what was audited.
|
|
115
161
|
- **Baseline:** `scan --update-baseline` writes `fringe.scan.baseline.json` (commit it); later scans hide those known findings and report `N baselined`. Matching is by rule, page/state and element, not axe's wording, so a contrast ratio that shifts slightly stays known.
|
|
116
162
|
|
package/dist/bin.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
3
|
import { existsSync as existsSync5, readFileSync as readFileSync11, statSync } from "fs";
|
|
4
|
-
import { dirname as
|
|
4
|
+
import { dirname as dirname3, join as join12, resolve } from "path";
|
|
5
5
|
|
|
6
6
|
// src/version.ts
|
|
7
|
-
var VERSION = "0.
|
|
7
|
+
var VERSION = "0.6.0";
|
|
8
8
|
|
|
9
9
|
// src/check.ts
|
|
10
10
|
import { readFileSync as readFileSync5 } from "fs";
|
|
@@ -1641,31 +1641,61 @@ function tabWalkFindings(url, focusables, visits) {
|
|
|
1641
1641
|
return out;
|
|
1642
1642
|
}
|
|
1643
1643
|
var STEP_KEYS = ["click", "fill", "press", "hover", "waitFor", "goto"];
|
|
1644
|
+
var bad = (m) => {
|
|
1645
|
+
throw new Error(`fringe.scan.json: ${m}`);
|
|
1646
|
+
};
|
|
1647
|
+
function checkSteps(steps, where2) {
|
|
1648
|
+
if (!Array.isArray(steps) || steps.length === 0) return bad(`${where2} needs a non-empty "steps" list`);
|
|
1649
|
+
for (const [k, step] of steps.entries()) {
|
|
1650
|
+
const keys = STEP_KEYS.filter((x) => step && typeof step === "object" && x in step);
|
|
1651
|
+
if (keys.length !== 1 || typeof step[keys[0]] !== "string") bad(`${where2} step ${k + 1} must be exactly one of ${STEP_KEYS.join(", ")} with a string value`);
|
|
1652
|
+
if (keys[0] === "fill" && typeof step.value !== "string") bad(`${where2} step ${k + 1}: fill needs a "value"`);
|
|
1653
|
+
}
|
|
1654
|
+
return steps;
|
|
1655
|
+
}
|
|
1644
1656
|
function parseStates(json) {
|
|
1645
|
-
const
|
|
1646
|
-
|
|
1647
|
-
};
|
|
1648
|
-
|
|
1649
|
-
if (!Array.isArray(states)) return bad('expected { "states": [ ... ] }');
|
|
1650
|
-
return states.map((s, i) => {
|
|
1657
|
+
const o = json;
|
|
1658
|
+
if (o?.states === void 0 && (o?.login !== void 0 || o?.sessions !== void 0 || o?.storageState !== void 0)) return [];
|
|
1659
|
+
if (!Array.isArray(o?.states)) return bad('expected { "states": [ ... ] }');
|
|
1660
|
+
return o.states.map((s, i) => {
|
|
1651
1661
|
if (typeof s?.name !== "string" || !s.name.trim()) bad(`states[${i}] needs a "name"`);
|
|
1652
|
-
|
|
1653
|
-
for (const [k, step] of s.steps.entries()) {
|
|
1654
|
-
const keys = STEP_KEYS.filter((x) => step && typeof step === "object" && x in step);
|
|
1655
|
-
if (keys.length !== 1 || typeof step[keys[0]] !== "string") bad(`state "${s.name}" step ${k + 1} must be exactly one of ${STEP_KEYS.join(", ")} with a string value`);
|
|
1656
|
-
if (keys[0] === "fill" && typeof step.value !== "string") bad(`state "${s.name}" step ${k + 1}: fill needs a "value"`);
|
|
1657
|
-
}
|
|
1658
|
-
return { name: s.name.trim(), steps: s.steps };
|
|
1662
|
+
return { name: s.name.trim(), steps: checkSteps(s.steps, `state "${s.name}"`) };
|
|
1659
1663
|
});
|
|
1660
1664
|
}
|
|
1665
|
+
function parseSessions(json, baseDir) {
|
|
1666
|
+
const o = json ?? {};
|
|
1667
|
+
const one = (x, where2, name) => {
|
|
1668
|
+
if (x?.storageState !== void 0 && typeof x.storageState !== "string") bad(`${where2}: storageState must be a file path`);
|
|
1669
|
+
if (x?.login !== void 0 && typeof x.login?.url !== "undefined" && typeof x.login.url !== "string") bad(`${where2}: login.url must be a string`);
|
|
1670
|
+
return { ...name ? { name } : {}, ...x?.storageState ? { storageState: join8(baseDir, x.storageState) } : {}, ...x?.login ? { login: { ...x.login.url ? { url: x.login.url } : {}, steps: checkSteps(x.login.steps, `${where2} login`) } } : {} };
|
|
1671
|
+
};
|
|
1672
|
+
const out = [];
|
|
1673
|
+
if (o.login !== void 0 || o.storageState !== void 0) out.push(one(o, "top level"));
|
|
1674
|
+
if (o.sessions !== void 0) {
|
|
1675
|
+
if (!Array.isArray(o.sessions) || o.sessions.length === 0) bad('"sessions" must be a non-empty list');
|
|
1676
|
+
for (const [i, s] of o.sessions.entries()) {
|
|
1677
|
+
if (typeof s?.name !== "string" || !s.name.trim()) bad(`sessions[${i}] needs a "name"`);
|
|
1678
|
+
if (!s.storageState && !s.login) bad(`session "${s.name}" needs a "storageState" or a "login"`);
|
|
1679
|
+
out.push(one(s, `session "${s.name}"`, s.name.trim()));
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
return out;
|
|
1683
|
+
}
|
|
1684
|
+
var ENV_REF = /\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
1685
|
+
var expandEnv = (s) => s.replace(ENV_REF, (_m, n) => {
|
|
1686
|
+
const v = process.env[n];
|
|
1687
|
+
if (v === void 0) throw new Error(`environment variable ${n} is not set (named in fringe.scan.json)`);
|
|
1688
|
+
return v;
|
|
1689
|
+
});
|
|
1690
|
+
var envNames = (steps) => steps.flatMap((st) => ("fill" in st ? [st.value] : "goto" in st ? [st.goto] : []).flatMap((v) => [...v.matchAll(ENV_REF)].map((m) => m[1])));
|
|
1661
1691
|
async function runStep(page, step, base) {
|
|
1662
1692
|
const t = { timeout: 1e4 };
|
|
1663
1693
|
if ("click" in step) await page.click(step.click, t);
|
|
1664
|
-
else if ("fill" in step) await page.fill(step.fill, step.value, t);
|
|
1694
|
+
else if ("fill" in step) await page.fill(step.fill, expandEnv(step.value), t);
|
|
1665
1695
|
else if ("press" in step) await page.keyboard.press(step.press);
|
|
1666
1696
|
else if ("hover" in step) await page.hover(step.hover, t);
|
|
1667
1697
|
else if ("waitFor" in step) await page.waitForSelector(step.waitFor, t);
|
|
1668
|
-
else await page.goto(new URL(step.goto, base).href, { waitUntil: "load", timeout: 3e4 });
|
|
1698
|
+
else await page.goto(new URL(expandEnv(step.goto), base).href, { waitUntil: "load", timeout: 3e4 });
|
|
1669
1699
|
await page.waitForTimeout(150);
|
|
1670
1700
|
}
|
|
1671
1701
|
async function audit(page, axePath, url) {
|
|
@@ -1683,12 +1713,12 @@ async function audit(page, axePath, url) {
|
|
|
1683
1713
|
}
|
|
1684
1714
|
return [...axeToFindings(url, axeRes.violations), ...tabWalkFindings(url, focusables, visits)];
|
|
1685
1715
|
}
|
|
1686
|
-
var inState = (name, url, f) => ({ ...f, message: `[${name}] ${f.message}`, loc: { ...f.loc, file: `${url} [${name}]` }, ...f.cluster ? { cluster: `${f.cluster} [${name}]` } : {} });
|
|
1716
|
+
var inState = (name, url, f) => ({ ...f, message: `[${name}] ${f.message}`, loc: { ...f.loc, file: `${f.loc.file === url ? url : f.loc.file} [${name}]` }, ...f.cluster ? { cluster: `${f.cluster} [${name}]` } : {} });
|
|
1687
1717
|
var MODES = ["dark", "reduced", "reflow"];
|
|
1688
1718
|
function parseModes(list) {
|
|
1689
1719
|
const out = list.split(",").map((m) => m.trim()).filter(Boolean);
|
|
1690
|
-
const
|
|
1691
|
-
if (
|
|
1720
|
+
const bad2 = out.find((m) => !MODES.includes(m));
|
|
1721
|
+
if (bad2 || out.length === 0) throw new Error(`unknown scan mode '${bad2 ?? ""}': use ${MODES.join(", ")}`);
|
|
1692
1722
|
return out;
|
|
1693
1723
|
}
|
|
1694
1724
|
function onlyNew(base, extra) {
|
|
@@ -1751,14 +1781,51 @@ function applyScanBaseline(cwd, findings) {
|
|
|
1751
1781
|
return { fresh, known: findings.length - fresh.length };
|
|
1752
1782
|
}
|
|
1753
1783
|
var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "page";
|
|
1784
|
+
var LOGINISH = /log-?in|sign-?in|sso|auth/i;
|
|
1785
|
+
var firstLine = (e) => String(e.message).split("\n")[0].slice(0, 120);
|
|
1786
|
+
async function loginRedirect(page, requested, who) {
|
|
1787
|
+
let now, want;
|
|
1788
|
+
try {
|
|
1789
|
+
now = new URL(page.url());
|
|
1790
|
+
want = new URL(requested);
|
|
1791
|
+
} catch {
|
|
1792
|
+
return [];
|
|
1793
|
+
}
|
|
1794
|
+
if (now.pathname === want.pathname) return [];
|
|
1795
|
+
const hasPassword = await page.evaluate(() => !!document.querySelector("input[type=password]"));
|
|
1796
|
+
if (!LOGINISH.test(now.pathname) && !hasPassword) return [];
|
|
1797
|
+
return [{ rule: "FR-405", severity: "review", message: `the scan was sent to ${now.pathname}, which looks like a login page: ${who ? `the "${who}" session may have expired or the login failed` : "this page needs a login"}`, hint: 'save a session with `fringe login <url>` or add a "login" block to fringe.scan.json; findings for this page describe the login screen, not your app', loc: { file: requested, line: 1, col: 0 } }];
|
|
1798
|
+
}
|
|
1799
|
+
async function auditContext(context, axePath, url, opts, who, shot) {
|
|
1800
|
+
const { states = [], modes = [] } = opts;
|
|
1801
|
+
const page = await context.newPage();
|
|
1802
|
+
await page.goto(url, { waitUntil: "load", timeout: 3e4 });
|
|
1803
|
+
await shot(page, "initial");
|
|
1804
|
+
const all = [...await loginRedirect(page, url, who), ...await audit(page, axePath, url)];
|
|
1805
|
+
const plain = [...all];
|
|
1806
|
+
for (const st of states) {
|
|
1807
|
+
await page.goto(url, { waitUntil: "load", timeout: 3e4 });
|
|
1808
|
+
let step = 0;
|
|
1809
|
+
try {
|
|
1810
|
+
for (; step < st.steps.length; step++) await runStep(page, st.steps[step], url);
|
|
1811
|
+
} catch (e) {
|
|
1812
|
+
all.push({ rule: "FR-402", severity: "review", message: `[${st.name}] state could not be reached: step ${step + 1} ${JSON.stringify(st.steps[step])} failed (${firstLine(e)})`, hint: "fix the step in fringe.scan.json or the page; nothing in this state was audited", loc: { file: `${url} [${st.name}]`, line: 1, col: 0 } });
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
await shot(page, `state-${st.name}`);
|
|
1816
|
+
all.push(...(await audit(page, axePath, url)).map((f) => inState(st.name, url, f)));
|
|
1817
|
+
}
|
|
1818
|
+
for (const m of modes) all.push(...await stress(context, axePath, url, m, plain, shot));
|
|
1819
|
+
return all;
|
|
1820
|
+
}
|
|
1754
1821
|
async function scan(url, cwd = process.cwd(), opts = {}) {
|
|
1755
1822
|
const { states = [], modes = [] } = opts;
|
|
1756
|
-
const shot = async (page, name) => {
|
|
1757
|
-
if (!opts.screenshots) return;
|
|
1758
|
-
mkdirSync3(opts.screenshots, { recursive: true });
|
|
1759
|
-
await page.screenshot({ path: join8(opts.screenshots, `${slug(name)}.png`), fullPage: true });
|
|
1760
|
-
};
|
|
1761
1823
|
if (!/^(https?|file):\/\//i.test(url)) throw new Error(`fringe scan takes an http(s):// or file:// URL, got '${url}'`);
|
|
1824
|
+
const sessions = opts.sessions?.length ? opts.sessions : [opts.storageState ? { storageState: opts.storageState } : {}];
|
|
1825
|
+
for (const n of /* @__PURE__ */ new Set([...sessions.flatMap((s) => envNames(s.login?.steps ?? [])), ...states.flatMap((s) => envNames(s.steps))])) {
|
|
1826
|
+
if (process.env[n] === void 0) throw new Error(`environment variable ${n} is not set (named in fringe.scan.json)`);
|
|
1827
|
+
}
|
|
1828
|
+
for (const s of sessions) if (s.storageState && !existsSync2(s.storageState)) throw new Error(`storage state file not found: ${s.storageState} (create one with: fringe login ${url})`);
|
|
1762
1829
|
const req = createRequire(join8(cwd, "noop.js"));
|
|
1763
1830
|
let playwright, axePath;
|
|
1764
1831
|
try {
|
|
@@ -1778,35 +1845,46 @@ async function scan(url, cwd = process.cwd(), opts = {}) {
|
|
|
1778
1845
|
}
|
|
1779
1846
|
}
|
|
1780
1847
|
try {
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
await
|
|
1790
|
-
let step = 0;
|
|
1848
|
+
const all = [];
|
|
1849
|
+
for (const s of sessions) {
|
|
1850
|
+
const who = s.name, label = who ? `as ${who}` : void 0;
|
|
1851
|
+
const shot = async (page, name) => {
|
|
1852
|
+
if (!opts.screenshots) return;
|
|
1853
|
+
mkdirSync3(opts.screenshots, { recursive: true });
|
|
1854
|
+
await page.screenshot({ path: join8(opts.screenshots, `${slug(who ? `${who}-${name}` : name)}.png`), fullPage: true });
|
|
1855
|
+
};
|
|
1856
|
+
const context = await browser.newContext(s.storageState ? { storageState: s.storageState } : {});
|
|
1791
1857
|
try {
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1858
|
+
let found;
|
|
1859
|
+
if (s.login) {
|
|
1860
|
+
const lp = await context.newPage();
|
|
1861
|
+
let step = 0;
|
|
1862
|
+
try {
|
|
1863
|
+
await lp.goto(new URL(s.login.url ?? url, url).href, { waitUntil: "load", timeout: 3e4 });
|
|
1864
|
+
for (; step < s.login.steps.length; step++) await runStep(lp, s.login.steps[step], url);
|
|
1865
|
+
found = await auditContext(context, axePath, url, opts, who, shot);
|
|
1866
|
+
} catch (e) {
|
|
1867
|
+
found = [{ rule: "FR-402", severity: "review", message: `login${who ? ` as ${who}` : ""} failed at step ${step + 1} ${JSON.stringify(s.login.steps[step] ?? {})} (${firstLine(e)})`, hint: "check the login steps and selectors in fringe.scan.json; nothing was scanned for this session", loc: { file: url, line: 1, col: 0 } }];
|
|
1868
|
+
} finally {
|
|
1869
|
+
await lp.close().catch(() => {
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
} else found = await auditContext(context, axePath, url, opts, who, shot);
|
|
1873
|
+
all.push(...label ? found.map((f) => inState(label, url, f)) : found);
|
|
1874
|
+
} finally {
|
|
1875
|
+
await context.close();
|
|
1796
1876
|
}
|
|
1797
|
-
await shot(page, `state-${st.name}`);
|
|
1798
|
-
all.push(...(await audit(page, axePath, url)).map((f) => inState(st.name, url, f)));
|
|
1799
1877
|
}
|
|
1800
|
-
|
|
1878
|
+
const files = sessions.length * (1 + states.length + modes.length);
|
|
1801
1879
|
if (opts.updateBaseline) {
|
|
1802
1880
|
writeFileSync3(baselinePath(cwd), JSON.stringify(all.map(scanKey).sort(), null, 2) + "\n");
|
|
1803
|
-
return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files
|
|
1881
|
+
return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files, skipped: [], findings: [], clusters: [], counts: { error: 0, review: 0, signal: 0 }, score: 100, baselined: all.length };
|
|
1804
1882
|
}
|
|
1805
1883
|
const { fresh, known } = applyScanBaseline(cwd, all);
|
|
1806
1884
|
const { findings, clusters } = rankScan(url, fresh);
|
|
1807
1885
|
const counts = { error: 0, review: 0, signal: 0 };
|
|
1808
1886
|
for (const f of findings) counts[f.severity]++;
|
|
1809
|
-
return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files
|
|
1887
|
+
return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files, skipped: [], findings, clusters, counts, score: score(counts, { error: 10, review: 3, signal: 1 }, 1), ...known ? { baselined: known } : {} };
|
|
1810
1888
|
} finally {
|
|
1811
1889
|
await browser.close();
|
|
1812
1890
|
}
|
|
@@ -1830,19 +1908,71 @@ function mergeScan(base, scanned, weights) {
|
|
|
1830
1908
|
return { ...base, findings: [...base.findings, ...scanned.findings], clusters: [...base.clusters ?? [], ...scanned.clusters ?? []].sort((a, b) => b.priority - a.priority), counts, score: score(counts, weights, base.files), ...(base.baselined ?? 0) + (scanned.baselined ?? 0) ? { baselined: (base.baselined ?? 0) + (scanned.baselined ?? 0) } : {} };
|
|
1831
1909
|
}
|
|
1832
1910
|
|
|
1833
|
-
// src/
|
|
1834
|
-
import {
|
|
1911
|
+
// src/login.ts
|
|
1912
|
+
import { createRequire as createRequire2 } from "module";
|
|
1913
|
+
import { mkdirSync as mkdirSync4 } from "fs";
|
|
1835
1914
|
import { dirname, join as join9 } from "path";
|
|
1915
|
+
import { createInterface } from "readline";
|
|
1916
|
+
async function saveSession(url, cwd, out, o = {}) {
|
|
1917
|
+
if (!/^https?:\/\//i.test(url)) throw new Error(`fringe login takes an http(s):// URL, got '${url}'`);
|
|
1918
|
+
const req = createRequire2(join9(cwd, "noop.js"));
|
|
1919
|
+
let playwright;
|
|
1920
|
+
try {
|
|
1921
|
+
playwright = req("playwright-core");
|
|
1922
|
+
} catch {
|
|
1923
|
+
throw new Error(SCAN_HELP);
|
|
1924
|
+
}
|
|
1925
|
+
let browser;
|
|
1926
|
+
const headless = o.headless ?? false;
|
|
1927
|
+
try {
|
|
1928
|
+
browser = await playwright.chromium.launch({ channel: "chrome", headless });
|
|
1929
|
+
} catch {
|
|
1930
|
+
try {
|
|
1931
|
+
browser = await playwright.chromium.launch({ headless });
|
|
1932
|
+
} catch {
|
|
1933
|
+
throw new Error(SCAN_HELP);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
try {
|
|
1937
|
+
const context = await browser.newContext();
|
|
1938
|
+
const page = await context.newPage();
|
|
1939
|
+
await page.goto(url, { waitUntil: "load", timeout: 6e4 });
|
|
1940
|
+
if (o.wait) await o.wait();
|
|
1941
|
+
else {
|
|
1942
|
+
process.stderr.write("fringe: log in in the browser window, then come back here and press Enter. Close the window afterwards, not before.\n");
|
|
1943
|
+
await new Promise((ok, fail) => {
|
|
1944
|
+
const rl = createInterface({ input: process.stdin });
|
|
1945
|
+
rl.once("line", () => {
|
|
1946
|
+
rl.close();
|
|
1947
|
+
ok();
|
|
1948
|
+
});
|
|
1949
|
+
browser.once("disconnected", () => {
|
|
1950
|
+
rl.close();
|
|
1951
|
+
fail(new Error("the browser was closed before the session was saved: press Enter in this terminal first, then close the window"));
|
|
1952
|
+
});
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
mkdirSync4(dirname(out), { recursive: true });
|
|
1956
|
+
await context.storageState({ path: out });
|
|
1957
|
+
} finally {
|
|
1958
|
+
await browser.close().catch(() => {
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// src/init.ts
|
|
1964
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync5, readFileSync as readFileSync9, readdirSync as readdirSync3, writeFileSync as writeFileSync4, copyFileSync } from "fs";
|
|
1965
|
+
import { dirname as dirname2, join as join10 } from "path";
|
|
1836
1966
|
import { fileURLToPath } from "url";
|
|
1837
1967
|
var HOOK = "npx -y @fringetools/cli check --claude-hook";
|
|
1838
1968
|
var SKILL_DIR = fileURLToPath(new URL("../skill/", import.meta.url));
|
|
1839
1969
|
function hookCommand(root) {
|
|
1840
|
-
return existsSync3(
|
|
1970
|
+
return existsSync3(join10(root, "node_modules", "@fringetools", "cli", "package.json")) ? "npx --no-install fringe check --claude-hook" : HOOK;
|
|
1841
1971
|
}
|
|
1842
1972
|
function isWired(root, home) {
|
|
1843
1973
|
for (const dir of [root, home]) {
|
|
1844
1974
|
try {
|
|
1845
|
-
if (/claude-hook|fringe(tool)?@/.test(readFileSync9(
|
|
1975
|
+
if (/claude-hook|fringe(tool)?@/.test(readFileSync9(join10(dir, ".claude", "settings.json"), "utf8"))) return true;
|
|
1846
1976
|
} catch {
|
|
1847
1977
|
}
|
|
1848
1978
|
}
|
|
@@ -1850,7 +1980,7 @@ function isWired(root, home) {
|
|
|
1850
1980
|
}
|
|
1851
1981
|
function initProject(root) {
|
|
1852
1982
|
const done = [];
|
|
1853
|
-
const settingsPath =
|
|
1983
|
+
const settingsPath = join10(root, ".claude", "settings.json");
|
|
1854
1984
|
let settings = {};
|
|
1855
1985
|
if (existsSync3(settingsPath)) {
|
|
1856
1986
|
try {
|
|
@@ -1863,16 +1993,16 @@ function initProject(root) {
|
|
|
1863
1993
|
if (/(fringe|@fringetools\/cli) check/.test(JSON.stringify(post))) done.push("hook already present");
|
|
1864
1994
|
else {
|
|
1865
1995
|
post.push({ matcher: "Edit|Write", hooks: [{ type: "command", command: hookCommand(root) }] });
|
|
1866
|
-
|
|
1996
|
+
mkdirSync5(dirname2(settingsPath), { recursive: true });
|
|
1867
1997
|
writeFileSync4(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
1868
1998
|
done.push(`hook added to ${settingsPath}`);
|
|
1869
1999
|
}
|
|
1870
2000
|
for (const name of readdirSync3(SKILL_DIR)) {
|
|
1871
|
-
const skillPath =
|
|
2001
|
+
const skillPath = join10(root, ".claude", "skills", name, "SKILL.md");
|
|
1872
2002
|
if (existsSync3(skillPath)) done.push(`skill ${name} already present`);
|
|
1873
2003
|
else {
|
|
1874
|
-
|
|
1875
|
-
copyFileSync(
|
|
2004
|
+
mkdirSync5(dirname2(skillPath), { recursive: true });
|
|
2005
|
+
copyFileSync(join10(SKILL_DIR, name, "SKILL.md"), skillPath);
|
|
1876
2006
|
done.push(`skill ${name} installed`);
|
|
1877
2007
|
}
|
|
1878
2008
|
}
|
|
@@ -1905,7 +2035,7 @@ function initFor(root, agents) {
|
|
|
1905
2035
|
for (const a of agents) {
|
|
1906
2036
|
const spec = AGENT_FILES[a];
|
|
1907
2037
|
if (!spec) throw new Error(`unknown agent '${a}': use ${AGENTS.join(", ")} (or 'claude' for the hook + skills)`);
|
|
1908
|
-
const file2 =
|
|
2038
|
+
const file2 = join10(root, spec.path);
|
|
1909
2039
|
const text = spec.wrap ? spec.wrap(body) : body;
|
|
1910
2040
|
if (existsSync3(file2)) {
|
|
1911
2041
|
const cur = readFileSync9(file2, "utf8");
|
|
@@ -1918,7 +2048,7 @@ ${MARK2}
|
|
|
1918
2048
|
${spec.wrap ? body : text}`);
|
|
1919
2049
|
done.push(`rules appended to ${spec.path}`);
|
|
1920
2050
|
} else {
|
|
1921
|
-
|
|
2051
|
+
mkdirSync5(dirname2(file2), { recursive: true });
|
|
1922
2052
|
writeFileSync4(file2, text.replace("# Fringe: handle your edges", `${MARK2}
|
|
1923
2053
|
# Fringe: handle your edges`));
|
|
1924
2054
|
done.push(`${spec.path} created`);
|
|
@@ -1927,10 +2057,10 @@ ${spec.wrap ? body : text}`);
|
|
|
1927
2057
|
return done;
|
|
1928
2058
|
}
|
|
1929
2059
|
function initGit(root) {
|
|
1930
|
-
const dir =
|
|
1931
|
-
if (!existsSync3(
|
|
1932
|
-
const file2 =
|
|
1933
|
-
const local = existsSync3(
|
|
2060
|
+
const dir = join10(root, ".git", "hooks");
|
|
2061
|
+
if (!existsSync3(join10(root, ".git"))) throw new Error("not a git repository (no .git here)");
|
|
2062
|
+
const file2 = join10(dir, "pre-commit");
|
|
2063
|
+
const local = existsSync3(join10(root, "node_modules", "@fringetools", "cli", "package.json"));
|
|
1934
2064
|
const cmd = local ? "npx --no-install fringe check" : "npx -y @fringetools/cli check";
|
|
1935
2065
|
const script = `#!/bin/sh
|
|
1936
2066
|
# fringe pre-commit: blocks the commit while error-level UX/accessibility problems remain
|
|
@@ -1946,7 +2076,7 @@ exit 0
|
|
|
1946
2076
|
if (readFileSync9(file2, "utf8").includes("# fringe pre-commit")) return ["pre-commit hook already present"];
|
|
1947
2077
|
throw new Error(`${file2} already exists and is not Fringe's: add 'npx -y @fringetools/cli check || exit 1' to it yourself`);
|
|
1948
2078
|
}
|
|
1949
|
-
|
|
2079
|
+
mkdirSync5(dir, { recursive: true });
|
|
1950
2080
|
writeFileSync4(file2, script, { mode: 493 });
|
|
1951
2081
|
return [`pre-commit hook installed at ${file2}`];
|
|
1952
2082
|
}
|
|
@@ -2079,12 +2209,12 @@ After each batch, verify: \`fringe check ${target}\``);
|
|
|
2079
2209
|
|
|
2080
2210
|
// src/rulereport.ts
|
|
2081
2211
|
import { existsSync as existsSync4, readFileSync as readFileSync10 } from "fs";
|
|
2082
|
-
import { join as
|
|
2212
|
+
import { join as join11 } from "path";
|
|
2083
2213
|
async function ruleReport(root) {
|
|
2084
2214
|
const cfg = loadConfig(root);
|
|
2085
2215
|
const result = await runCheck(root, cfg, false);
|
|
2086
2216
|
const baselinedBy = /* @__PURE__ */ new Map();
|
|
2087
|
-
const bl =
|
|
2217
|
+
const bl = join11(root, "fringe.baseline.json");
|
|
2088
2218
|
if (existsSync4(bl)) {
|
|
2089
2219
|
try {
|
|
2090
2220
|
for (const k of JSON.parse(readFileSync10(bl, "utf8"))) baselinedBy.set(k.split("|")[0], (baselinedBy.get(k.split("|")[0]) ?? 0) + 1);
|
|
@@ -2207,7 +2337,7 @@ function verifyToMarkdown(v) {
|
|
|
2207
2337
|
}
|
|
2208
2338
|
|
|
2209
2339
|
// src/index.ts
|
|
2210
|
-
import { mkdirSync as
|
|
2340
|
+
import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
2211
2341
|
var log = (o) => o.log ?? ((s) => console.log(s));
|
|
2212
2342
|
var err = (o) => o.err ?? ((s) => process.stderr.write(s));
|
|
2213
2343
|
function readManifest(path2, warn) {
|
|
@@ -2238,13 +2368,13 @@ function readManifest(path2, warn) {
|
|
|
2238
2368
|
}
|
|
2239
2369
|
function resolveTarget(arg) {
|
|
2240
2370
|
if (!statSync(arg).isFile()) return { root: arg };
|
|
2241
|
-
let dir =
|
|
2242
|
-
for (let d = dir, i = 0; i < 12; i++, d =
|
|
2243
|
-
if (existsSync5(
|
|
2371
|
+
let dir = dirname3(arg);
|
|
2372
|
+
for (let d = dir, i = 0; i < 12; i++, d = dirname3(d)) {
|
|
2373
|
+
if (existsSync5(join12(d, "package.json")) || existsSync5(join12(d, "fringe.config.json"))) {
|
|
2244
2374
|
dir = d;
|
|
2245
2375
|
break;
|
|
2246
2376
|
}
|
|
2247
|
-
if (
|
|
2377
|
+
if (dirname3(d) === d) break;
|
|
2248
2378
|
}
|
|
2249
2379
|
return { root: dir, only: /* @__PURE__ */ new Set([arg]) };
|
|
2250
2380
|
}
|
|
@@ -2254,9 +2384,10 @@ function loadScanOptions(cwd, rest) {
|
|
|
2254
2384
|
return i === -1 ? void 0 : rest[i + 1];
|
|
2255
2385
|
};
|
|
2256
2386
|
const si = rest.indexOf("--states");
|
|
2257
|
-
const file2 = si !== -1 ? resolve(cwd, rest[si + 1] ?? "") :
|
|
2387
|
+
const file2 = si !== -1 ? resolve(cwd, rest[si + 1] ?? "") : join12(cwd, "fringe.scan.json");
|
|
2258
2388
|
let states = [];
|
|
2259
|
-
let
|
|
2389
|
+
let sessions;
|
|
2390
|
+
const storageFlag = flag("--storage-state") ? resolve(cwd, flag("--storage-state")) : void 0;
|
|
2260
2391
|
if (si !== -1 || existsSync5(file2)) {
|
|
2261
2392
|
let raw;
|
|
2262
2393
|
try {
|
|
@@ -2265,10 +2396,11 @@ function loadScanOptions(cwd, rest) {
|
|
|
2265
2396
|
throw new Error(`${file2}: ${e.message}`);
|
|
2266
2397
|
}
|
|
2267
2398
|
states = parseStates(raw);
|
|
2268
|
-
|
|
2399
|
+
const parsed = parseSessions(raw, dirname3(file2));
|
|
2400
|
+
if (parsed.length) sessions = parsed;
|
|
2269
2401
|
}
|
|
2270
2402
|
const shots = flag("--screenshots");
|
|
2271
|
-
return { states, modes: loadModes(rest), ...
|
|
2403
|
+
return { states, modes: loadModes(rest), ...storageFlag ? { storageState: storageFlag } : sessions ? { sessions } : {}, ...shots ? { screenshots: resolve(cwd, shots) } : {}, ...rest.includes("--update-baseline") ? { updateBaseline: true } : {} };
|
|
2272
2404
|
}
|
|
2273
2405
|
function loadModes(rest) {
|
|
2274
2406
|
const i = rest.indexOf("--modes");
|
|
@@ -2278,6 +2410,7 @@ var weakEvidence = (e) => typeof e !== "string" || e.trim().length < 12 || /^(n\
|
|
|
2278
2410
|
var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
|
|
2279
2411
|
fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
|
|
2280
2412
|
fringe check --update-baseline [path] accept today's findings; only new ones gate
|
|
2413
|
+
fringe login <url> [--out auth.json] log in once in a browser window and save the session for scans
|
|
2281
2414
|
fringe scan <url> [--states f] [--stress | --modes ...] [--storage-state auth.json] [--screenshots dir] [--update-baseline] [--json] runtime check in a real browser (axe-core + keyboard walk); needs playwright-core and axe-core
|
|
2282
2415
|
fringe rules [dir] [--json] per rule: open / baselined / silenced, to spot rules that don't suit this repo
|
|
2283
2416
|
fringe handoff [dir|file] [--scan url] a work order for another agent or CI (saved to .fringe/handoff.json)
|
|
@@ -2405,6 +2538,27 @@ async function runCli(argv, opts = {}) {
|
|
|
2405
2538
|
return result.counts.error > 0 ? 1 : 0;
|
|
2406
2539
|
} catch (e) {
|
|
2407
2540
|
err(opts)(`fringe: ${e.message}
|
|
2541
|
+
`);
|
|
2542
|
+
return 2;
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
if (cmd === "login") {
|
|
2546
|
+
try {
|
|
2547
|
+
const url = rest.find((a, i) => !a.startsWith("--") && rest[i - 1] !== "--out");
|
|
2548
|
+
if (!url) {
|
|
2549
|
+
err(opts)("fringe: login needs a URL, e.g. fringe login http://localhost:3000/login\n");
|
|
2550
|
+
return 2;
|
|
2551
|
+
}
|
|
2552
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2553
|
+
const oi = rest.indexOf("--out");
|
|
2554
|
+
const out = resolve(cwd, oi !== -1 ? rest[oi + 1] ?? "" : ".fringe/auth.json");
|
|
2555
|
+
await saveSession(url, cwd, out, { headless: rest.includes("--headless"), wait: opts.waitForUser });
|
|
2556
|
+
log(opts)(`fringe: session saved to ${out}
|
|
2557
|
+
It holds your logged-in cookies: keep it out of git. Use it with: fringe scan <url> --storage-state ${oi !== -1 ? rest[oi + 1] : ".fringe/auth.json"}`);
|
|
2558
|
+
if (oi === -1 && existsSync5(join12(cwd, ".gitignore")) && !/\.fringe/.test(readFileSync11(join12(cwd, ".gitignore"), "utf8"))) log(opts)("fringe: your .gitignore does not list .fringe/: add it, or the session file could be committed.");
|
|
2559
|
+
return 0;
|
|
2560
|
+
} catch (e) {
|
|
2561
|
+
err(opts)(`fringe: ${e.message}
|
|
2408
2562
|
`);
|
|
2409
2563
|
return 2;
|
|
2410
2564
|
}
|
|
@@ -2431,7 +2585,7 @@ async function runCli(argv, opts = {}) {
|
|
|
2431
2585
|
const h = buildHandoff(g.result, { root: g.root, target: g.target, ...g.only ? { only: g.only } : {}, ...g.scan ? { scan: g.scan } : {} }, g.limit);
|
|
2432
2586
|
const oi = rest.indexOf("--out");
|
|
2433
2587
|
const outFile = resolve(g.root, oi !== -1 ? rest[oi + 1] ?? "" : ".fringe/handoff.json");
|
|
2434
|
-
|
|
2588
|
+
mkdirSync6(dirname3(outFile), { recursive: true });
|
|
2435
2589
|
writeFileSync5(outFile, JSON.stringify(h, null, 2) + "\n");
|
|
2436
2590
|
log(opts)(rest.includes("--json") ? JSON.stringify(h, null, 2) : h.prompt);
|
|
2437
2591
|
err(opts)(`fringe: hand-off saved to ${outFile}; the receiving agent proves it is done with: fringe verify${oi !== -1 ? ` --handoff ${outFile}` : ""}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ interface CliOpts {
|
|
|
2
2
|
log?: (s: string) => void;
|
|
3
3
|
cwd?: string;
|
|
4
4
|
err?: (s: string) => void; /** Home directory for `init --global` (tests override it). */
|
|
5
|
-
home?: string;
|
|
5
|
+
home?: string; /** `fringe login` waits for this instead of Enter on the terminal (tests). */
|
|
6
|
+
waitForUser?: () => Promise<void>;
|
|
6
7
|
}
|
|
7
8
|
declare function runCli(argv: string[], opts?: CliOpts): Promise<number>;
|
|
8
9
|
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -27,6 +27,7 @@ Fringe checks agent-written frontends for accessibility, state, dark-pattern and
|
|
|
27
27
|
| `check --update-baseline` | Accept today's findings; only new ones gate |
|
|
28
28
|
| `scan <url>` | Runtime check of a live page: real contrast, keyboard walk, and named states from `fringe.scan.json` (needs `playwright-core` + `axe-core`) |
|
|
29
29
|
| `plan [dir\|file] [--scan url]` | Ordered fix batches (shared components first) with recipes; `--scan` adds the live page |
|
|
30
|
+
| `login <url>` | Log in once in a browser window and save the session, so `scan` can reach pages behind a sign-in |
|
|
30
31
|
| `handoff` / `verify` | Work order for another agent or CI, and proof it was done (complete / partial / regressed) |
|
|
31
32
|
| `rules` | Per rule: open / baselined / silenced, to spot rules that do not suit this repo |
|
|
32
33
|
| `init --for <agents>` / `--git` | Rules file for Codex, Cursor, Copilot, Gemini…; commit gate for any agent |
|