@seybel/breakhound 1.0.5 → 1.0.7
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 +19 -333
- package/dist/cli.js +189 -84
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -16,11 +16,8 @@ breakhound 1.0.0 · 14 pages · 320–1920px
|
|
|
16
16
|
✖ /pricing 2 issues · layout changes at 768, 1024 4.1s
|
|
17
17
|
✔ /about clean · layout changes at 640, 1024 3.2s
|
|
18
18
|
✖ /blog/[slug] 1 issue · layout changes at 768 3.8s as /blog/hello-world
|
|
19
|
-
○ /dashboard Needs a login: it redirected to /login
|
|
20
19
|
...
|
|
21
20
|
|
|
22
|
-
3 issues on 2 pages
|
|
23
|
-
|
|
24
21
|
✖ error 320–599px Page scrolls horizontally (280px wider than the viewport)
|
|
25
22
|
div.pricing-table · /pricing
|
|
26
23
|
→ It stays 600px wide no matter the screen size. Replace the fixed width
|
|
@@ -30,11 +27,8 @@ breakhound 1.0.0 · 14 pages · 320–1920px
|
|
|
30
27
|
→ Collapse this row into a menu below this width, tighten its spacing, or
|
|
31
28
|
let it scroll sideways.
|
|
32
29
|
|
|
33
|
-
○ 1 page needs a login. Run npx @seybel/breakhound login once to sign in, then scan again.
|
|
34
|
-
|
|
35
30
|
Report .breakhound/report.html (npx @seybel/breakhound report to open it)
|
|
36
|
-
Fix it npx @seybel/breakhound
|
|
37
|
-
or npx @seybel/breakhound mcp install so your agent can scan, verify and undo by itself
|
|
31
|
+
Fix it npx @seybel/breakhound mcp install so your agent can scan, verify and undo by itself
|
|
38
32
|
```
|
|
39
33
|
|
|
40
34
|
## Quick start
|
|
@@ -45,357 +39,49 @@ In your project folder:
|
|
|
45
39
|
npx @seybel/breakhound
|
|
46
40
|
```
|
|
47
41
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
npm i -g @seybel/breakhound
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
Or add it to your project with `npm i -D @seybel/breakhound`, so everyone gets the same version, and run it as `npx breakhound` or from an npm script.
|
|
55
|
-
|
|
56
|
-
If your dev server is already running it's used, otherwise breakhound starts it with your `dev` script and stops it when done. Chromium is downloaded automatically the first time (about 150 MB).
|
|
57
|
-
|
|
58
|
-
Other ways to run it:
|
|
59
|
-
|
|
60
|
-
```bash
|
|
61
|
-
npx @seybel/breakhound /pricing /blog # only these routes
|
|
62
|
-
npx @seybel/breakhound http://localhost:3000 # just this one page
|
|
63
|
-
npx @seybel/breakhound --url https://staging.example.com # every route, on another server
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
## Finding routes
|
|
67
|
-
|
|
68
|
-
| Project | Where routes come from |
|
|
69
|
-
|---|---|
|
|
70
|
-
| Next.js (App and Pages Router) | `app/` and `pages/`, skipping route groups, parallel slots, private folders and API routes |
|
|
71
|
-
| Nuxt | the live Vue router, so module and custom routes count too (falls back to `pages/`) |
|
|
72
|
-
| SvelteKit | `src/routes` |
|
|
73
|
-
| Astro | `src/pages` |
|
|
74
|
-
| Remix, React Router 7 | the route manifest, `app/routes.ts` and `app/routes/` |
|
|
75
|
-
| TanStack Router | `routeTree.gen.ts` and the live router |
|
|
76
|
-
| React Router (code-defined) | the live route tree, including `<Routes>` nested inside pages; falls back to reading your router code |
|
|
77
|
-
| Vue Router (code-defined) | the live router; falls back to reading your router code |
|
|
78
|
-
| Angular | your route definitions, from `src/main.ts` on, with lazily loaded `loadChildren` routes under their path |
|
|
79
|
-
| Anything else | links followed from the home page (up to 50 pages, a few per section) |
|
|
80
|
-
|
|
81
|
-
**Dynamic routes** like `/blog/[slug]` are scanned once, using a real link found on your pages (routes that share a template share a layout). Routes below one reuse its values: once `/users/jane` is found, `/users/[username]/settings` is scanned as `/users/jane/settings`. If something is still missing, it's listed with a hint naming the parameters. Give them a real value once, by name, and every route that uses them is filled in:
|
|
82
|
-
|
|
83
|
-
```ts
|
|
84
|
-
params: { username: 'jane', orderId: '1001' }
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
Or give one route its own URL, which wins over `params`:
|
|
88
|
-
|
|
89
|
-
```ts
|
|
90
|
-
samples: { '/products/[id]': '/products/42' }
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
**Pages behind a login** are spotted (they redirect to a sign-in page) and listed instead of scanning the login page over and over. Sign in once:
|
|
94
|
-
|
|
95
|
-
```bash
|
|
96
|
-
npx @seybel/breakhound login
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
A browser window opens; log in as you normally would, then press Enter. The session is saved to `.breakhound/auth.json`, which is never committed (see below), and used by later scans. breakhound never sees your password.
|
|
100
|
-
|
|
101
|
-
Links that would log you out, delete something, or download a file are never followed.
|
|
102
|
-
|
|
103
|
-
## What it checks
|
|
104
|
-
|
|
105
|
-
| Check | Finds |
|
|
106
|
-
|---|---|
|
|
107
|
-
| `page-overflow` | Anything that makes the page scroll sideways, or gets cut off at the screen edge (including inside full-width wrappers) |
|
|
108
|
-
| `content-overflow` | Content spilling out of a card, panel or bordered box, sideways or out of the bottom of a fixed-height box |
|
|
109
|
-
| `media-overflow` | Images, video and embeds that don't shrink with their container |
|
|
110
|
-
| `overlap` | Content painted on top of other content |
|
|
111
|
-
| `unexpected-wrap` | Nav rows and button labels breaking onto extra lines |
|
|
112
|
-
| `clipped-text` | Text cut off, or pushed out of sight, by a fixed-size container at narrower widths |
|
|
113
|
-
| `squished-text` | Columns so narrow that words break apart |
|
|
114
|
-
| `distorted-media` | Images stretched or squashed out of proportion |
|
|
115
|
-
| `small-tap-target` | Buttons and links under 24×24px that crowd each other on touch screens (WCAG 2.2) |
|
|
116
|
-
| `tiny-text` | Text under 12px on phones |
|
|
117
|
-
| `oversized-text` | Headings and display text left at their desktop size on phones, wrapping a word or two a line |
|
|
118
|
-
| `edge-gutter` | Text pressed against the edge of a phone screen |
|
|
119
|
-
| `viewport-meta` | A missing responsive viewport tag |
|
|
120
|
-
|
|
121
|
-
Every issue comes with the exact width ranges where it happens, the element to blame, the numbers behind it, a screenshot, and a likely fix. breakhound also reports the widths where your layout actually changes and flags changes that no media or container query explains.
|
|
122
|
-
|
|
123
|
-
It's built to be quiet: carousels, off-canvas menus, badges, decorative shapes, ellipsis and line-clamp, and framework dev overlays are all understood and left alone. It also looks inside web components (open shadow roots) and handles right-to-left pages.
|
|
124
|
-
|
|
125
|
-
### Safari and Firefox
|
|
126
|
-
|
|
127
|
-
Chromium finds most layout bugs, but Safari and Firefox lay some things out differently (flex gaps, `dvh` units, form controls, font metrics). `--browsers chromium,webkit,firefox` checks the pages again in WebKit (Safari's engine) and Firefox once Chromium is done, one browser at a time so memory stays flat. An issue that shows up in only some of them says so: `(only in WebKit)`. Each extra browser is a one-time download of about 80 to 90 MB, done on first use or ahead of time with `npx @seybel/breakhound install webkit firefox`.
|
|
128
|
-
|
|
129
|
-
Two differences you'll meet early: Safari gives buttons, inputs and selects an 11px font unless you set one (Chrome and Firefox use about 13px), so unstyled controls show up as tiny text only in WebKit, with `font: inherit` as the fix (that's WebKit on a Mac: Playwright's WebKit on Linux, as in most CI, draws them like Chrome); and Firefox breaks long URLs at their slashes, so a URL that pushes the page wide in Chrome and Safari can wrap fine in Firefox.
|
|
130
|
-
|
|
131
|
-
### Menus, dialogs, tabs and expandable sections
|
|
132
|
-
|
|
133
|
-
A lot of what breaks on a phone is hidden until you tap: the mobile menu, a sign-up modal, an FAQ answer, the second tab. After scanning a page as it loads, breakhound opens what it finds there one at a time and sweeps the widths where each can be opened. That covers buttons with `aria-expanded`, `<details>`, popovers, dialogs, tabs (`role="tab"`, as in Radix, shadcn/ui, Headless UI and MUI), Bootstrap toggles, and buttons that are clearly a menu (`hamburger`, `menu-toggle`, an accessible name of "Menu"). Only what breaks while something is open gets reported, with what to open first:
|
|
134
|
-
|
|
135
|
-
```
|
|
136
|
-
✖ 320–399px Page scrolls horizontally (80px wider than the viewport), with "Menu" open
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
It never clicks a link to another page or a button that submits a form, and it opens only one question of an FAQ, since they all share a layout, but every tab, since each shows something different. It opens up to 5 per page. Set `states` to change that, or `--no-states` (or `data-breakhound-ignore="states"` on an element) to leave them closed.
|
|
140
|
-
|
|
141
|
-
### A second opinion from a vision model (optional)
|
|
142
|
-
|
|
143
|
-
Rules can't tell when a layout is merely cramped or awkward. `--vision` shows each page at 375, 768 and 1280px to your own vision model (an Anthropic or OpenAI key, or a local Ollama model such as `qwen2.5vl`, set up the same way as `breakhound fix`) and adds what it says looks off. Those come in as info, tied to the element whose text the model quotes, and never fail a run. Verify leaves them out, since an opinion can't be measured again.
|
|
144
|
-
|
|
145
|
-
## Reports
|
|
42
|
+
No config needed. It works with Next.js, Nuxt, SvelteKit, Astro, Remix, React Router, TanStack Router, Vue, Angular and plain Vite apps, at the root of a monorepo too, and follows links on anything else. Chromium downloads the first time (about 150 MB).
|
|
146
43
|
|
|
147
|
-
|
|
44
|
+
To type `breakhound` on its own, install it globally with `npm i -g @seybel/breakhound`, or add it to your project with `npm i -D @seybel/breakhound`.
|
|
148
45
|
|
|
149
|
-
|
|
150
|
-
|---|---|
|
|
151
|
-
| `report.html` | Self-contained report: width map, filters, screenshots, copy buttons. Open it with `breakhound report`. |
|
|
152
|
-
| `summary.md` | One line per issue |
|
|
153
|
-
| `report.md` | Every issue with evidence and screenshots |
|
|
154
|
-
| `prompt.md` | Instructions for a coding agent |
|
|
155
|
-
| `result.json` | The raw result |
|
|
156
|
-
| `report.pdf` | With `--pdf` |
|
|
46
|
+
## What it finds
|
|
157
47
|
|
|
158
|
-
|
|
48
|
+
Pages that scroll sideways, content spilling out of its box, media that doesn't shrink, overlapping content, nav rows and labels that wrap, clipped and squished text, text too small or too big for phones, small tap targets, distorted images, text against the screen edge, and a missing viewport tag.
|
|
159
49
|
|
|
160
|
-
|
|
50
|
+
Every issue comes with the exact widths where it happens, the element to blame, the numbers behind it, a screenshot, and a likely fix. It also opens menus, dialogs, tabs and accordions and checks them open, can check Safari's and Firefox's engines too, and is built to stay quiet on carousels, off-canvas menus and other patterns that only look wrong. [All the checks →](https://seybel.github.io/breakhound/guide/checks)
|
|
161
51
|
|
|
162
|
-
|
|
52
|
+
## Three ways to use it
|
|
163
53
|
|
|
164
|
-
|
|
165
|
-
- the timeline: the layout bands, the media query widths, the changes no media or container query explains, and a bar for each issue across the widths it covers
|
|
54
|
+
**In the terminal**, as above. Pages behind a login, dynamic routes, staging servers and big apps are all covered in [recipes](https://seybel.github.io/breakhound/guide/recipes).
|
|
166
55
|
|
|
167
|
-
|
|
56
|
+
**In Chrome**, with the [breakhound extension](https://chromewebstore.google.com/detail/ddnmhnjmcokbfabeileblceehjhefkpc): right-click any page, from localhost to production, and choose **Scan with breakhound**. A timeline fills in live as the page grows from 320px to 1920px. [More →](https://seybel.github.io/breakhound/guide/extension)
|
|
168
57
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
While it scans or shows a width, Chrome displays a bar saying breakhound started debugging this browser: that is how an extension resizes a tab. The bar goes away afterwards, and the tab goes back to its own width and scroll position. **Reload first** reloads the page before scanning, so breakhound can wait for the page's own resize handlers exactly, as the CLI does. Leave it off to scan the page as you have it.
|
|
172
|
-
|
|
173
|
-
**Whole site** scans every page of the site, starting from the one you're on. breakhound finds the pages the way the CLI does: from the app's live router when it has one (Vue, React Router, Nuxt, Remix, TanStack), otherwise by following links from the page you're on and the home page (never a logout, delete or file link, and a few pages per section, since blog posts share a layout), up to 25 pages. It loads each one in the same tab, scans it, and comes back to where you were. Chrome stops drawing a tab that's out of view, so if you switch away while it runs, the scan pauses and carries on by itself when you come back. Your own login applies, so pages behind it are scanned too. Each page gets a chip with its issue count; an issue shared by several pages is counted once, and **Copy prompt** lists it once with every page it's on. Showing an issue from another page takes the tab there first.
|
|
174
|
-
|
|
175
|
-
**Send to agent** hands the issues to the coding agent working on that app, so it can fix them and check each fix in your browser. See [From the browser](#from-the-browser) below.
|
|
176
|
-
|
|
177
|
-
Routes only your project's files list (Next.js, SvelteKit and Astro route folders) need the CLI, as do menus opened, other browsers, screenshots and reports.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
## Fix it with your coding agent
|
|
181
|
-
|
|
182
|
-
breakhound doesn't edit your code: your own agent does, and breakhound checks its work. Nothing leaves your machine and it costs nothing.
|
|
183
|
-
|
|
184
|
-
### Over MCP (recommended)
|
|
58
|
+
**With your coding agent.** breakhound doesn't edit your code: your agent does, and breakhound checks its work at every width.
|
|
185
59
|
|
|
186
60
|
```bash
|
|
187
61
|
npx @seybel/breakhound mcp install
|
|
188
62
|
```
|
|
189
63
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
| Tool | What it does |
|
|
193
|
-
|---|---|
|
|
194
|
-
| `scan` | Find every route and its issues. This becomes the baseline |
|
|
195
|
-
| `list_routes`, `list_issues` | What was found |
|
|
196
|
-
| `get_issue` | Widths, element, source file and line, measurements, likely fix and a screenshot |
|
|
197
|
-
| `verify` | Scan again and compare with the baseline: fixed, still there, and **new** |
|
|
198
|
-
| `checkpoint` | Commit the fix for some routes as its own git commit |
|
|
199
|
-
| `undo` | Revert the fixes for some routes, or the whole session |
|
|
200
|
-
|
|
201
|
-
`verify` is the point: agents often fix 812px and break 1280px, or fix one page and break another that shares a component. New issues show up there, at any width, on any page that was scanned.
|
|
202
|
-
|
|
203
|
-
The dev server stays up between calls, so each verify only takes as long as the scan. In Claude Code, `/mcp__breakhound__fix` asks for the whole loop in one go.
|
|
204
|
-
|
|
205
|
-
### From the browser
|
|
206
|
-
|
|
207
|
-
Scanned a page in the breakhound extension? Click **Send to agent** and the issues go to the agent working on that app, with nothing to copy and paste. Then ask it to "fix the breakhound issues".
|
|
208
|
-
|
|
209
|
-
- It goes to the project, not to one agent: Claude Code and Cursor open on the same app both see it.
|
|
210
|
-
- breakhound picks the project serving the page, the way the CLI recognises your dev server (or from `baseUrl` in your config). When it can't tell, for a staging site say, it asks which project.
|
|
211
|
-
- When the agent verifies a fix, the panel scans those pages again **in your browser**: same login, same browser, so the comparison is exact. Keep the panel open while it works; a chip in the toolbar shows the agent is connected. If the panel is closed, verify says so, and the agent asks you to open it.
|
|
212
|
-
|
|
213
|
-
It works through the MCP server, so it needs `npx @seybel/breakhound mcp install` once (the panel shows the command if your agent isn't connected yet). `breakhound mcp` listens on 127.0.0.1 only, on a port from 47821, and answers only the breakhound extension. It only ever writes the scan you sent into `.breakhound/`; it never reads or sends your files.
|
|
214
|
-
|
|
215
|
-
### With a prompt
|
|
216
|
-
|
|
217
|
-
```bash
|
|
218
|
-
npx @seybel/breakhound prompt --copy
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
Paste it into any agent. It lists each unique issue once with the widths, the element, the evidence and the likely fix, and tells the agent to run `npx @seybel/breakhound verify` until nothing is left.
|
|
222
|
-
|
|
223
|
-
### With your own model
|
|
224
|
-
|
|
225
|
-
No agent at hand? `breakhound fix` works through the last scan's issues itself, using your own API key or a model running on your machine:
|
|
226
|
-
|
|
227
|
-
```bash
|
|
228
|
-
ANTHROPIC_API_KEY=... npx @seybel/breakhound fix # or OPENAI_API_KEY=... with --model <name>
|
|
229
|
-
npx @seybel/breakhound fix --provider ollama --model qwen2.5-coder # free and local, with Ollama
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
For each issue it shows the model the issue and the files most likely behind it (where the element is written, then the files that mention its classes), applies the edits it suggests, and checks the pages again. A fix that clears the issue without breaking anything else, at any width, becomes its own commit, which `breakhound undo` can take back. One that doesn't is put back, and the model gets one more try with the reason. Files with uncommitted changes are never touched. It needs a git repository, and only the code around each issue is sent to the model you chose.
|
|
233
|
-
|
|
234
|
-
### Verify, checkpoint, undo
|
|
235
|
-
|
|
236
|
-
The same loop works from the command line:
|
|
237
|
-
|
|
238
|
-
```bash
|
|
239
|
-
npx @seybel/breakhound verify /pricing # compare with the last full scan
|
|
240
|
-
npx @seybel/breakhound checkpoint /pricing -m "Let the pricing table shrink"
|
|
241
|
-
npx @seybel/breakhound undo /pricing # revert just that fix
|
|
242
|
-
npx @seybel/breakhound undo --session # revert every fix since the scan
|
|
243
|
-
npx @seybel/breakhound undo # list the fixes you can undo
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
Checkpoints are ordinary commits with a `Breakhound-Route:` trailer, and undo makes a new commit that reverts them: history is never rewritten. breakhound never commits changes you had before the scan (it stops and says so), and undo refuses to run over uncommitted work.
|
|
247
|
-
|
|
248
|
-
### Point the agent at the right line
|
|
249
|
-
|
|
250
|
-
In development, breakhound reads where elements come from out of React's and Vue's dev info, which usually gives the component and its file. For the exact line of every element, add the plugin (development only, production builds are untouched):
|
|
251
|
-
|
|
252
|
-
```ts
|
|
253
|
-
// vite.config.ts (React, Vue, Svelte, Preact, Solid; Nuxt: under `vite.plugins`)
|
|
254
|
-
import breakhound from '@seybel/breakhound/vite';
|
|
255
|
-
export default defineConfig({ plugins: [breakhound(), react()] });
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
```js
|
|
259
|
-
// next.config.mjs (Turbopack and webpack)
|
|
260
|
-
import { withBreakhound } from '@seybel/breakhound/next';
|
|
261
|
-
export default withBreakhound({ /* your config */ });
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
```js
|
|
265
|
-
// webpack.config.js
|
|
266
|
-
const { BreakhoundPlugin } = require('@seybel/breakhound/webpack');
|
|
267
|
-
module.exports = { plugins: [new BreakhoundPlugin()] };
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
Each HTML element gets a `data-bh-src="src/components/Nav.tsx:42:7"` attribute on the dev server, and reports and prompts show `Source: src/components/Nav.tsx:42`.
|
|
271
|
-
|
|
272
|
-
## Configuration
|
|
273
|
-
|
|
274
|
-
Optional. `npx @seybel/breakhound init` creates one. If breakhound is in your dev dependencies you get `breakhound.config.ts` (or `.mjs` without TypeScript), fully typed:
|
|
275
|
-
|
|
276
|
-
```ts
|
|
277
|
-
import { defineConfig } from '@seybel/breakhound';
|
|
278
|
-
|
|
279
|
-
export default defineConfig({
|
|
280
|
-
exclude: ['/admin/**'], // or include: ['/blog/**']
|
|
281
|
-
samples: { '/products/[id]': '/products/42' },
|
|
282
|
-
widths: { min: 320, max: 1920 },
|
|
283
|
-
ignore: ['.intercom-launcher', { selector: '.map', detectors: ['overlap'] }],
|
|
284
|
-
detectors: { 'tiny-text': false, 'edge-gutter': 'info' },
|
|
285
|
-
failOn: 'error',
|
|
286
|
-
});
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
Running breakhound through `npx` without installing it, you get `breakhound.config.json` instead. A TypeScript file importing a package that isn't installed would break your project's own type check (and builds that run it, like `next build`). The JSON file points at breakhound's schema, so your editor still suggests and explains every option:
|
|
290
|
-
|
|
291
|
-
```json
|
|
292
|
-
{
|
|
293
|
-
"$schema": "https://cdn.jsdelivr.net/npm/@seybel/breakhound/dist/schema.json",
|
|
294
|
-
"exclude": ["/admin/**"]
|
|
295
|
-
}
|
|
296
|
-
```
|
|
297
|
-
|
|
298
|
-
`.js`, `.cjs` and `.mts` configs work too. The types ship for both `import` and `require`.
|
|
299
|
-
|
|
300
|
-
| Option | What it does |
|
|
301
|
-
|---|---|
|
|
302
|
-
| `baseUrl` | Scan this server instead of finding or starting the dev server |
|
|
303
|
-
| `routes` | Scan exactly these routes instead of discovering them |
|
|
304
|
-
| `include`, `exclude` | Route globs: `*` is one segment, `**` any number |
|
|
305
|
-
| `samples` | A real URL for a dynamic route |
|
|
306
|
-
| `params` | Real values for dynamic route parameters by name, for every route that uses them |
|
|
307
|
-
| `maxRoutes` | Most routes per run (default 100) |
|
|
308
|
-
| `server` | How to start the app: `{ command: 'npm run preview', port: 4173, timeout: 120 }` |
|
|
309
|
-
| `widths` | `{ min, max, step }` in CSS pixels |
|
|
310
|
-
| `ignore`, `detectors` | Leave elements out, turn checks off or change their severity |
|
|
311
|
-
| `failOn` | Exit 1 at `error`, `warning`, `info` or `none` |
|
|
312
|
-
| `vision` | `true` to add a vision model's opinion, or `{ model, provider, widths }` |
|
|
313
|
-
| `fix` | The model `breakhound fix` uses: `{ provider: 'ollama', model: 'qwen2.5-coder' }` |
|
|
314
|
-
| `browsers` | Engines to check in: `["chromium", "webkit", "firefox"]` (default Chromium only) |
|
|
315
|
-
| `states` | Most menus, dialogs, tabs and expandable sections to open per page (default 5), or `false` |
|
|
316
|
-
| `concurrency` | Browser tabs to use at once (default half your CPU cores, up to 4) |
|
|
317
|
-
| `memoryLimit` | Most memory the dev server and browser may use together, e.g. `"6GB"`, `"50%"` or `"off"` (default 60% of your memory) |
|
|
318
|
-
|
|
319
|
-
You can also mark elements in your markup:
|
|
320
|
-
|
|
321
|
-
```html
|
|
322
|
-
<div data-breakhound-ignore>...</div> <!-- every check -->
|
|
323
|
-
<div data-breakhound-ignore="overlap small-tap-target">...</div>
|
|
324
|
-
```
|
|
64
|
+
That sets it up for Claude Code, Cursor, VS Code, Antigravity, Windsurf and Codex. Then ask your agent to fix the responsive issues: it can scan, verify that each fix didn't break another width or page, commit each fix on its own, and undo it. No MCP? `npx @seybel/breakhound prompt --copy` gives you instructions to paste into any agent. [More →](https://seybel.github.io/breakhound/guide/agents)
|
|
325
65
|
|
|
326
|
-
##
|
|
327
|
-
|
|
328
|
-
Run these with `npx @seybel/breakhound`, or as `breakhound` after `npm i -g @seybel/breakhound`. Installed in a project, it's `npx breakhound` or an npm script.
|
|
329
|
-
|
|
330
|
-
```
|
|
331
|
-
breakhound Find your app and scan every route
|
|
332
|
-
breakhound /pricing /blog Scan only these routes of your app
|
|
333
|
-
breakhound <url or file ...> Scan just these pages
|
|
334
|
-
breakhound login [route] Log in once in a browser window; later scans reuse the session
|
|
335
|
-
breakhound report Open the last HTML report
|
|
336
|
-
breakhound prompt Print the fix prompt (--copy to copy it, --all to include info)
|
|
337
|
-
breakhound init Create a config file
|
|
338
|
-
breakhound install [browsers] Download Chromium, or webkit and firefox (--with-deps adds Linux libraries)
|
|
339
|
-
|
|
340
|
-
breakhound verify [routes] Scan again and compare with the last full scan (--all to count info)
|
|
341
|
-
breakhound checkpoint <routes> Commit the current changes as the fix for these routes (-m message)
|
|
342
|
-
breakhound undo [routes] Revert the fixes for these routes (--session for all)
|
|
343
|
-
breakhound mcp install Add the MCP server to your agents (--client, --yes)
|
|
344
|
-
breakhound mcp Run the MCP server (your agent starts it)
|
|
345
|
-
|
|
346
|
-
--url <url> Where your app runs (skips finding the dev server)
|
|
347
|
-
--include <glob> Only scan matching routes (repeatable)
|
|
348
|
-
--exclude <glob> Skip matching routes (repeatable)
|
|
349
|
-
--max-routes <n> Most routes to scan (default 100)
|
|
350
|
-
--min <px> --max <px> Width range (default 320 to 1920)
|
|
351
|
-
--fail-on <level> Exit 1 at error, warning, info or none (default error)
|
|
352
|
-
--pdf Also write report.pdf
|
|
353
|
-
--no-screenshots Skip screenshots
|
|
354
|
-
--no-states Don't open menus, dialogs, tabs and expandable sections
|
|
355
|
-
--browsers <list> Also check other engines, e.g. chromium,webkit,firefox
|
|
356
|
-
--vision Also ask your own vision model what looks off (info only)
|
|
357
|
-
--concurrency <n> Browser tabs to use at once
|
|
358
|
-
--memory-limit <size> Memory the dev server and browser may use, e.g. 6GB, 50% or off
|
|
359
|
-
--out <dir> Report folder (default .breakhound)
|
|
360
|
-
--open Open the report when done
|
|
361
|
-
-q, --quiet Only print errors
|
|
362
|
-
```
|
|
363
|
-
|
|
364
|
-
While it runs, breakhound watches the memory of the dev server it started (and everything that server runs) and of its browser, along with how much memory your system has left. If they grow past `memoryLimit`, or the system runs out of memory, it shuts them down at once and tells you what used the memory and what to try. It only ever stops processes it started itself.
|
|
365
|
-
|
|
366
|
-
Exit codes: `0` clean, `1` issues at or above `--fail-on`, `2` a page couldn't be checked (it failed to load, answered with an HTTP error, or rendered nothing). Pages behind a login and dynamic routes without a URL are reported but don't fail the run.
|
|
367
|
-
|
|
368
|
-
## CI
|
|
369
|
-
|
|
370
|
-
On GitHub Actions, after installing your dependencies:
|
|
66
|
+
## In CI
|
|
371
67
|
|
|
372
68
|
```yaml
|
|
373
69
|
- uses: Seybel/breakhound@v1
|
|
374
70
|
with:
|
|
375
71
|
fail-on: error
|
|
376
|
-
# url: ${{ steps.deploy.outputs.preview-url }} # or scan a preview deployment
|
|
377
72
|
```
|
|
378
73
|
|
|
379
|
-
It starts your dev server (or scans
|
|
74
|
+
It starts your dev server (or scans a preview deployment), turns each issue into an annotation, and uploads the report as an artifact. [More →](https://seybel.github.io/breakhound/guide/ci)
|
|
380
75
|
|
|
381
|
-
|
|
76
|
+
## Docs
|
|
382
77
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
npm install
|
|
387
|
-
npx playwright install chromium
|
|
388
|
-
npm test # accuracy suite in real Chromium, plus unit tests
|
|
389
|
-
npm run typecheck
|
|
390
|
-
npm run lint
|
|
391
|
-
npm run build # builds the publishable package in packages/cli/dist
|
|
392
|
-
npm run build:extension # builds the DevTools panel in packages/extension/dist
|
|
393
|
-
npm run dev -- http://localhost:3000
|
|
394
|
-
```
|
|
78
|
+
- [Getting started](https://seybel.github.io/breakhound/guide/getting-started) and [your first scan](https://seybel.github.io/breakhound/guide/first-scan)
|
|
79
|
+
- [Recipes](https://seybel.github.io/breakhound/guide/recipes) for common jobs
|
|
80
|
+
- [Configuration](https://seybel.github.io/breakhound/reference/config) and the [CLI](https://seybel.github.io/breakhound/reference/cli)
|
|
395
81
|
|
|
396
|
-
|
|
82
|
+
## Contributing
|
|
397
83
|
|
|
398
|
-
Found a false positive, or a bug breakhound missed? That's the most useful thing to report
|
|
84
|
+
Found a false positive, or a bug breakhound missed? That's the most useful thing to report. See [CONTRIBUTING.md](https://github.com/Seybel/breakhound/blob/main/CONTRIBUTING.md) for that and for working on the code.
|
|
399
85
|
|
|
400
86
|
## License
|
|
401
87
|
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { existsSync as existsSync16 } from "fs";
|
|
5
|
-
import { join as
|
|
5
|
+
import { join as join19, resolve as resolve7 } from "path";
|
|
6
6
|
import { parseArgs, styleText as styleText9 } from "util";
|
|
7
7
|
|
|
8
8
|
// ../engine/src/index.ts
|
|
@@ -2107,11 +2107,12 @@ async function printPdf(html, file) {
|
|
|
2107
2107
|
|
|
2108
2108
|
// src/auth.ts
|
|
2109
2109
|
import { existsSync as existsSync7, writeFileSync as writeFileSync2 } from "fs";
|
|
2110
|
-
import { dirname as dirname4, join as
|
|
2110
|
+
import { dirname as dirname4, join as join9 } from "path";
|
|
2111
2111
|
import { createInterface } from "readline";
|
|
2112
2112
|
import { styleText as styleText3 } from "util";
|
|
2113
2113
|
|
|
2114
2114
|
// src/app.ts
|
|
2115
|
+
import { join as join7 } from "path";
|
|
2115
2116
|
import { styleText } from "util";
|
|
2116
2117
|
|
|
2117
2118
|
// ../routes/src/ast.ts
|
|
@@ -2917,8 +2918,8 @@ function staticRoutes(project) {
|
|
|
2917
2918
|
}
|
|
2918
2919
|
|
|
2919
2920
|
// ../routes/src/project.ts
|
|
2920
|
-
import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
|
|
2921
|
-
import { dirname as dirname2, join as join5 } from "path";
|
|
2921
|
+
import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync5 } from "fs";
|
|
2922
|
+
import { dirname as dirname2, join as join5, relative as relative3 } from "path";
|
|
2922
2923
|
var FRAMEWORKS = {
|
|
2923
2924
|
next: { name: "Next.js", routing: "files" },
|
|
2924
2925
|
nuxt: { name: "Nuxt", routing: "code" },
|
|
@@ -3052,11 +3053,69 @@ function normalizeBasePath(path) {
|
|
|
3052
3053
|
if (!trimmed) return "";
|
|
3053
3054
|
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
3054
3055
|
}
|
|
3056
|
+
var RUNS_PACKAGES = /\b(turbo|nx|lerna|moon)\b|\bpnpm\b.*\s(-r|--recursive|--filter|-F)\b|\b(npm|yarn|bun)\b.*\s(--workspaces?|-w|--filter|workspaces?)\b/;
|
|
3057
|
+
function workspaceDirs(root, pkg) {
|
|
3058
|
+
const globs = Array.isArray(pkg.workspaces) ? [...pkg.workspaces] : [...pkg.workspaces?.packages ?? []];
|
|
3059
|
+
const pnpm = join5(root, "pnpm-workspace.yaml");
|
|
3060
|
+
if (existsSync4(pnpm)) {
|
|
3061
|
+
let inPackages = false;
|
|
3062
|
+
for (const line of readFileSync5(pnpm, "utf8").split(/\r?\n/)) {
|
|
3063
|
+
if (/^packages\s*:/.test(line)) inPackages = true;
|
|
3064
|
+
else if (/^\S/.test(line)) inPackages = false;
|
|
3065
|
+
else if (inPackages) {
|
|
3066
|
+
const item = line.match(/^\s*-\s*['"]?([^'"#]+?)['"]?\s*(?:#.*)?$/)?.[1];
|
|
3067
|
+
if (item) globs.push(item);
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
const expand = (dir, parts, depth = 0) => {
|
|
3072
|
+
const [part, ...rest2] = parts;
|
|
3073
|
+
if (part === void 0) return [dir];
|
|
3074
|
+
if (part === "." || part === "") return expand(dir, rest2, depth);
|
|
3075
|
+
if (!part.includes("*"))
|
|
3076
|
+
return existsSync4(join5(dir, part)) ? expand(join5(dir, part), rest2, depth) : [];
|
|
3077
|
+
let subdirs;
|
|
3078
|
+
try {
|
|
3079
|
+
subdirs = readdirSync2(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && d.name !== "node_modules" && !d.name.startsWith(".")).map((d) => d.name);
|
|
3080
|
+
} catch {
|
|
3081
|
+
return [];
|
|
3082
|
+
}
|
|
3083
|
+
if (part === "**") {
|
|
3084
|
+
return [
|
|
3085
|
+
...expand(dir, rest2, depth),
|
|
3086
|
+
...depth < 3 ? subdirs.flatMap((d) => expand(join5(dir, d), parts, depth + 1)) : []
|
|
3087
|
+
];
|
|
3088
|
+
}
|
|
3089
|
+
const match = new RegExp(
|
|
3090
|
+
`^${part.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`
|
|
3091
|
+
);
|
|
3092
|
+
return subdirs.filter((d) => match.test(d)).flatMap((d) => expand(join5(dir, d), rest2, depth));
|
|
3093
|
+
};
|
|
3094
|
+
const of = (globs2) => globs2.flatMap((g) => expand(root, g.replace(/\/+$/, "").split("/")));
|
|
3095
|
+
const left = new Set(of(globs.filter((g) => g.startsWith("!")).map((g) => g.slice(1))));
|
|
3096
|
+
return [...new Set(of(globs.filter((g) => !g.startsWith("!"))))].filter((d) => !left.has(d));
|
|
3097
|
+
}
|
|
3098
|
+
function workspaceApps(root, pkg) {
|
|
3099
|
+
return workspaceDirs(root, pkg).filter((dir) => {
|
|
3100
|
+
const app = readJson(join5(dir, "package.json"));
|
|
3101
|
+
if (!app) return false;
|
|
3102
|
+
const framework = detectFramework({ ...app.dependencies, ...app.devDependencies });
|
|
3103
|
+
const script = pickDevScript(app.scripts ?? {});
|
|
3104
|
+
return framework.id !== "unknown" && !!script && DEV_SERVER.test(script.command);
|
|
3105
|
+
});
|
|
3106
|
+
}
|
|
3055
3107
|
function detectProject(cwd) {
|
|
3056
3108
|
const pkg = readJson(join5(cwd, "package.json"));
|
|
3057
3109
|
if (!pkg) return null;
|
|
3058
3110
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
3059
3111
|
const framework = detectFramework(deps);
|
|
3112
|
+
const own = pickDevScript(pkg.scripts ?? {});
|
|
3113
|
+
const isApp = framework.id !== "unknown" && !!own && !RUNS_PACKAGES.test(own.command);
|
|
3114
|
+
const apps = isApp ? [] : workspaceApps(cwd, pkg);
|
|
3115
|
+
if (apps.length === 1) {
|
|
3116
|
+
const app = detectProject(apps[0]);
|
|
3117
|
+
if (app) return { ...app, workspace: relative3(cwd, apps[0]) };
|
|
3118
|
+
}
|
|
3060
3119
|
const devScript = pickDevScript(pkg.scripts ?? {});
|
|
3061
3120
|
const defaultPort = DEFAULT_PORT[framework.id];
|
|
3062
3121
|
const configured = [...new Set(configuredPorts(cwd, devScript?.command))];
|
|
@@ -3068,7 +3127,8 @@ function detectProject(cwd) {
|
|
|
3068
3127
|
devScript,
|
|
3069
3128
|
ports: [...new Set(ports)],
|
|
3070
3129
|
configuredPorts: configured,
|
|
3071
|
-
basePath: detectBasePath(cwd)
|
|
3130
|
+
basePath: detectBasePath(cwd),
|
|
3131
|
+
...apps.length > 1 ? { apps: apps.map((a) => relative3(cwd, a)) } : {}
|
|
3072
3132
|
};
|
|
3073
3133
|
}
|
|
3074
3134
|
|
|
@@ -3266,14 +3326,14 @@ var RouteQueue = class {
|
|
|
3266
3326
|
function readRuntimeRoutes() {
|
|
3267
3327
|
const w = window;
|
|
3268
3328
|
const paths = /* @__PURE__ */ new Set();
|
|
3269
|
-
const
|
|
3329
|
+
const join20 = (base, path) => path.startsWith("/") ? path : `${base.replace(/\/?\*$/, "").replace(/\/+$/, "")}/${path}`;
|
|
3270
3330
|
const manifest = w.__reactRouterManifest?.routes ?? w.__remixManifest?.routes;
|
|
3271
3331
|
if (manifest && typeof manifest === "object") {
|
|
3272
3332
|
const fullPath = (id, depth = 0) => {
|
|
3273
3333
|
const r = manifest[id];
|
|
3274
3334
|
if (!r || depth > 50) return "";
|
|
3275
3335
|
const parent = r.parentId ? fullPath(r.parentId, depth + 1) : "";
|
|
3276
|
-
return r.path ?
|
|
3336
|
+
return r.path ? join20(parent, r.path) : parent;
|
|
3277
3337
|
};
|
|
3278
3338
|
for (const id of Object.keys(manifest)) {
|
|
3279
3339
|
const r = manifest[id];
|
|
@@ -3320,7 +3380,7 @@ function readRuntimeRoutes() {
|
|
|
3320
3380
|
if (e?.props?.children && !e.props.path) walkElements(e.props.children, base);
|
|
3321
3381
|
continue;
|
|
3322
3382
|
}
|
|
3323
|
-
const full = typeof e.props.path === "string" ?
|
|
3383
|
+
const full = typeof e.props.path === "string" ? join20(base, e.props.path) : base;
|
|
3324
3384
|
if (typeof e.props.path === "string") paths.add(full);
|
|
3325
3385
|
else if (e.props.index) paths.add(indexPath2(base));
|
|
3326
3386
|
walkElements(e.props.children, full);
|
|
@@ -3330,7 +3390,7 @@ function readRuntimeRoutes() {
|
|
|
3330
3390
|
if (!Array.isArray(routes) || depth > 30) return;
|
|
3331
3391
|
for (const r of routes) {
|
|
3332
3392
|
if (!r || typeof r !== "object") continue;
|
|
3333
|
-
const full = typeof r.path === "string" ?
|
|
3393
|
+
const full = typeof r.path === "string" ? join20(base, r.path) : base;
|
|
3334
3394
|
if (typeof r.path === "string") paths.add(full);
|
|
3335
3395
|
else if (r.index) paths.add(indexPath2(base));
|
|
3336
3396
|
walkObjects(r.children, full, depth + 1);
|
|
@@ -3342,7 +3402,7 @@ function readRuntimeRoutes() {
|
|
|
3342
3402
|
if (Array.isArray(matches) && matches.length) {
|
|
3343
3403
|
let base = "";
|
|
3344
3404
|
for (const m of matches)
|
|
3345
|
-
if (typeof m.route?.path === "string") base =
|
|
3405
|
+
if (typeof m.route?.path === "string") base = join20(base, m.route.path);
|
|
3346
3406
|
return base;
|
|
3347
3407
|
}
|
|
3348
3408
|
}
|
|
@@ -3371,7 +3431,7 @@ function readRuntimeRoutes() {
|
|
|
3371
3431
|
if (Array.isArray(matches)) {
|
|
3372
3432
|
let base = "";
|
|
3373
3433
|
for (const m of matches) {
|
|
3374
|
-
if (typeof m?.route?.path === "string") base =
|
|
3434
|
+
if (typeof m?.route?.path === "string") base = join20(base, m.route.path);
|
|
3375
3435
|
if (Array.isArray(m?.route?.children)) {
|
|
3376
3436
|
kind ??= "react-router";
|
|
3377
3437
|
walkObjects(m.route.children, base);
|
|
@@ -3555,7 +3615,7 @@ async function startServer(project, options = {}) {
|
|
|
3555
3615
|
}
|
|
3556
3616
|
const timeoutMs = options.timeoutMs ?? 12e4;
|
|
3557
3617
|
const child = spawn(command, {
|
|
3558
|
-
cwd: project.root,
|
|
3618
|
+
cwd: options.cwd ?? project.root,
|
|
3559
3619
|
shell: true,
|
|
3560
3620
|
detached: process.platform !== "win32",
|
|
3561
3621
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -3592,6 +3652,13 @@ async function startServer(project, options = {}) {
|
|
|
3592
3652
|
let failingSince = null;
|
|
3593
3653
|
while (Date.now() < deadline) {
|
|
3594
3654
|
if (child.exitCode !== null) {
|
|
3655
|
+
const printed2 = printedUrl;
|
|
3656
|
+
const page = printed2 ? await probe(`${printed2}/`, 5e3) : null;
|
|
3657
|
+
if (printed2 && page && page.status < 500 && looksLikeProject(project, page, true)) {
|
|
3658
|
+
process.removeListener("exit", cleanup);
|
|
3659
|
+
return { url: printed2, alreadyRunning: true, stop: async () => {
|
|
3660
|
+
} };
|
|
3661
|
+
}
|
|
3595
3662
|
throw new Error(
|
|
3596
3663
|
`The dev server (${command}) stopped before it was ready:
|
|
3597
3664
|
${output.slice(-15).join("\n")}`
|
|
@@ -3946,9 +4013,34 @@ async function resolveServer(cwd, config, quiet) {
|
|
|
3946
4013
|
"No package.json here, so there is no app to find. Run breakhound in your project folder, or pass a URL: npx @seybel/breakhound http://localhost:3000"
|
|
3947
4014
|
);
|
|
3948
4015
|
}
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
4016
|
+
const withPort = (p) => {
|
|
4017
|
+
const port = config.server?.port;
|
|
4018
|
+
if (!port) return p;
|
|
4019
|
+
p.ports = [port, ...p.ports.filter((q) => q !== port)];
|
|
4020
|
+
p.configuredPorts = [port, ...p.configuredPorts];
|
|
4021
|
+
return p;
|
|
4022
|
+
};
|
|
4023
|
+
withPort(project);
|
|
4024
|
+
if (project.workspace) log(quiet, styleText("dim", `Using the app in ${project.workspace}`));
|
|
4025
|
+
if (project.apps && !config.server?.command) {
|
|
4026
|
+
const found = await Promise.all(
|
|
4027
|
+
project.apps.map(async (dir2) => {
|
|
4028
|
+
const found2 = detectProject(join7(cwd, dir2));
|
|
4029
|
+
const app2 = found2 && withPort(found2);
|
|
4030
|
+
const running3 = app2 ? await findRunningServer(app2) : null;
|
|
4031
|
+
return app2 && running3 ? { dir: dir2, app: app2, running: running3 } : null;
|
|
4032
|
+
})
|
|
4033
|
+
);
|
|
4034
|
+
const live = found.filter((f) => f !== null);
|
|
4035
|
+
if (live.length !== 1 || !live[0]) {
|
|
4036
|
+
throw new Error(
|
|
4037
|
+
`This is a monorepo with ${project.apps.length} apps (${project.apps.join(", ")}). Run breakhound in the one to scan, e.g. cd ${project.apps[0]} && npx @seybel/breakhound, or start its dev server first.`
|
|
4038
|
+
);
|
|
4039
|
+
}
|
|
4040
|
+
const { dir, app, running: running2 } = live[0];
|
|
4041
|
+
const baseUrl2 = withBasePath(running2.url, app.basePath);
|
|
4042
|
+
log(quiet, `${styleText("green", "\u2714")} ${app.framework.name} in ${dir} running at ${baseUrl2}`);
|
|
4043
|
+
return { project: { ...app, workspace: dir }, baseUrl: baseUrl2, server: null };
|
|
3952
4044
|
}
|
|
3953
4045
|
const running = config.server?.command ? null : await findRunningServer(project);
|
|
3954
4046
|
if (running) {
|
|
@@ -3961,7 +4053,8 @@ async function resolveServer(cwd, config, quiet) {
|
|
|
3961
4053
|
log(quiet, styleText("dim", `Starting ${name ?? "the app"} with ${label}...`));
|
|
3962
4054
|
let pid;
|
|
3963
4055
|
const started = await startServer(project, {
|
|
3964
|
-
|
|
4056
|
+
// A command from the config runs where breakhound was run, like the user would run it.
|
|
4057
|
+
...command ? { command, cwd } : {},
|
|
3965
4058
|
...config.server?.port ? { port: config.server.port } : {},
|
|
3966
4059
|
...config.server?.timeout ? { timeoutMs: config.server.timeout * 1e3 } : {},
|
|
3967
4060
|
onSpawn: (id) => {
|
|
@@ -3972,6 +4065,12 @@ async function resolveServer(cwd, config, quiet) {
|
|
|
3972
4065
|
guard.untrackServer(pid);
|
|
3973
4066
|
throw error;
|
|
3974
4067
|
});
|
|
4068
|
+
if (started.alreadyRunning) {
|
|
4069
|
+
guard.untrackServer(pid);
|
|
4070
|
+
const baseUrl2 = withBasePath(started.url, project.basePath);
|
|
4071
|
+
log(quiet, `${styleText("green", "\u2714")} ${name ?? "App"} already running at ${baseUrl2}`);
|
|
4072
|
+
return { project, baseUrl: baseUrl2, server: null };
|
|
4073
|
+
}
|
|
3975
4074
|
const server2 = {
|
|
3976
4075
|
...started,
|
|
3977
4076
|
stop: async () => {
|
|
@@ -4058,7 +4157,7 @@ async function planRoutes(app, config, scanner, explicit) {
|
|
|
4058
4157
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
4059
4158
|
import { existsSync as existsSync6, readFileSync as readFileSync8 } from "fs";
|
|
4060
4159
|
import { createRequire as createRequire2 } from "module";
|
|
4061
|
-
import { dirname as dirname3, join as
|
|
4160
|
+
import { dirname as dirname3, join as join8 } from "path";
|
|
4062
4161
|
import { styleText as styleText2 } from "util";
|
|
4063
4162
|
var DOWNLOAD_MB = { chromium: 150, webkit: 80, firefox: 90 };
|
|
4064
4163
|
function installed(name) {
|
|
@@ -4086,7 +4185,7 @@ function playwrightCli() {
|
|
|
4086
4185
|
const manifest = require2.resolve("playwright/package.json");
|
|
4087
4186
|
const { bin } = JSON.parse(readFileSync8(manifest, "utf8"));
|
|
4088
4187
|
const file = typeof bin === "string" ? bin : bin?.playwright ?? "cli.js";
|
|
4089
|
-
return
|
|
4188
|
+
return join8(dirname3(manifest), file);
|
|
4090
4189
|
}
|
|
4091
4190
|
function installBrowsers(names, withDeps) {
|
|
4092
4191
|
const cli = playwrightCli();
|
|
@@ -4117,7 +4216,7 @@ async function runLogin(cwd, config, options = {}) {
|
|
|
4117
4216
|
};
|
|
4118
4217
|
ensureBrowsers();
|
|
4119
4218
|
const app = await resolveServer(cwd, config, options.quiet ?? false);
|
|
4120
|
-
const file =
|
|
4219
|
+
const file = join9(cwd, AUTH_FILE);
|
|
4121
4220
|
const browser = await launch(Boolean(automate));
|
|
4122
4221
|
try {
|
|
4123
4222
|
const context = await browser.newContext({
|
|
@@ -4243,7 +4342,7 @@ function copyToClipboard(text2) {
|
|
|
4243
4342
|
|
|
4244
4343
|
// src/config.ts
|
|
4245
4344
|
import { existsSync as existsSync8, readFileSync as readFileSync10 } from "fs";
|
|
4246
|
-
import { join as
|
|
4345
|
+
import { join as join10, resolve as resolve2 } from "path";
|
|
4247
4346
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4248
4347
|
var CONFIG_FILES = [
|
|
4249
4348
|
"breakhound.config.ts",
|
|
@@ -4261,7 +4360,7 @@ function selfModule() {
|
|
|
4261
4360
|
return "@seybel/breakhound";
|
|
4262
4361
|
}
|
|
4263
4362
|
async function loadConfig(cwd, explicit) {
|
|
4264
|
-
const file = explicit ? resolve2(cwd, explicit) : CONFIG_FILES.map((f) =>
|
|
4363
|
+
const file = explicit ? resolve2(cwd, explicit) : CONFIG_FILES.map((f) => join10(cwd, f)).find((f) => existsSync8(f));
|
|
4265
4364
|
if (!file) return { config: {}, file: null, warnings: [] };
|
|
4266
4365
|
if (!existsSync8(file)) throw new Error(`Config file not found: ${file}`);
|
|
4267
4366
|
let config;
|
|
@@ -4444,14 +4543,14 @@ function normalizeIgnore(ignore) {
|
|
|
4444
4543
|
// src/fix.ts
|
|
4445
4544
|
import { execFileSync } from "child_process";
|
|
4446
4545
|
import { existsSync as existsSync12, readFileSync as readFileSync14, statSync as statSync3, writeFileSync as writeFileSync6 } from "fs";
|
|
4447
|
-
import { join as
|
|
4546
|
+
import { join as join14, normalize as normalize2 } from "path";
|
|
4448
4547
|
import { styleText as styleText6 } from "util";
|
|
4449
4548
|
|
|
4450
4549
|
// src/session.ts
|
|
4451
4550
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
4452
4551
|
import { randomBytes } from "crypto";
|
|
4453
4552
|
import { existsSync as existsSync9, readFileSync as readFileSync11, realpathSync, writeFileSync as writeFileSync3 } from "fs";
|
|
4454
|
-
import { join as
|
|
4553
|
+
import { join as join11, relative as relative4, resolve as resolve3, sep as sep2 } from "path";
|
|
4455
4554
|
var SESSION_FILE = "session.json";
|
|
4456
4555
|
function git(cwd, args, input) {
|
|
4457
4556
|
const run2 = spawnSync5("git", args, {
|
|
@@ -4484,7 +4583,7 @@ function fromRoot(root, cwd, path) {
|
|
|
4484
4583
|
base = realpathSync(cwd);
|
|
4485
4584
|
} catch {
|
|
4486
4585
|
}
|
|
4487
|
-
return
|
|
4586
|
+
return relative4(root, resolve3(base, path)).split(sep2).join("/");
|
|
4488
4587
|
}
|
|
4489
4588
|
function changedFiles(cwd, outDir = ".breakhound") {
|
|
4490
4589
|
const root = gitRoot(cwd);
|
|
@@ -4503,7 +4602,7 @@ function changedFiles(cwd, outDir = ".breakhound") {
|
|
|
4503
4602
|
return files.filter((f) => f !== skip && !f.startsWith(`${skip}/`));
|
|
4504
4603
|
}
|
|
4505
4604
|
function sessionPath(cwd, outDir) {
|
|
4506
|
-
return
|
|
4605
|
+
return join11(resolve3(cwd, outDir), SESSION_FILE);
|
|
4507
4606
|
}
|
|
4508
4607
|
function readSession(cwd, outDir = ".breakhound") {
|
|
4509
4608
|
const file = sessionPath(cwd, outDir);
|
|
@@ -4710,11 +4809,11 @@ function undo(cwd, req) {
|
|
|
4710
4809
|
|
|
4711
4810
|
// src/verify.ts
|
|
4712
4811
|
import { existsSync as existsSync11, readFileSync as readFileSync13, writeFileSync as writeFileSync5 } from "fs";
|
|
4713
|
-
import { join as
|
|
4812
|
+
import { join as join13, resolve as resolve5 } from "path";
|
|
4714
4813
|
|
|
4715
4814
|
// src/scan.ts
|
|
4716
|
-
import { existsSync as existsSync10, readdirSync as
|
|
4717
|
-
import { isAbsolute, join as
|
|
4815
|
+
import { existsSync as existsSync10, readdirSync as readdirSync3, readFileSync as readFileSync12, rmSync, statSync as statSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
4816
|
+
import { isAbsolute, join as join12, relative as relative6, resolve as resolve4, sep as sep3 } from "path";
|
|
4718
4817
|
import { fileURLToPath as fileURLToPath3, pathToFileURL } from "url";
|
|
4719
4818
|
import { styleText as styleText5 } from "util";
|
|
4720
4819
|
|
|
@@ -4872,7 +4971,7 @@ ${SETUP}`
|
|
|
4872
4971
|
}
|
|
4873
4972
|
|
|
4874
4973
|
// src/print.ts
|
|
4875
|
-
import { relative as
|
|
4974
|
+
import { relative as relative5 } from "path";
|
|
4876
4975
|
import { styleText as styleText4 } from "util";
|
|
4877
4976
|
var BADGE = {
|
|
4878
4977
|
error: styleText4("red", "\u2716 error "),
|
|
@@ -4949,7 +5048,7 @@ function printIssues(result) {
|
|
|
4949
5048
|
}
|
|
4950
5049
|
function printFooter(paths, result, cwd) {
|
|
4951
5050
|
const rel = (p) => {
|
|
4952
|
-
const r =
|
|
5051
|
+
const r = relative5(cwd, p);
|
|
4953
5052
|
return r.startsWith("..") ? p : r;
|
|
4954
5053
|
};
|
|
4955
5054
|
console.log("");
|
|
@@ -5134,7 +5233,7 @@ function routeOf(url, cwd) {
|
|
|
5134
5233
|
const u = new URL(url);
|
|
5135
5234
|
if (u.protocol === "file:") {
|
|
5136
5235
|
const path = fileURLToPath3(u);
|
|
5137
|
-
const rel =
|
|
5236
|
+
const rel = relative6(cwd, path);
|
|
5138
5237
|
return rel.startsWith("..") ? path : rel;
|
|
5139
5238
|
}
|
|
5140
5239
|
return `${u.pathname}${u.search}` || "/";
|
|
@@ -5142,9 +5241,9 @@ function routeOf(url, cwd) {
|
|
|
5142
5241
|
var SCREENSHOT_FILE = /^[0-9a-f]{10}\.png$/;
|
|
5143
5242
|
function clearScreenshots(dir) {
|
|
5144
5243
|
if (!existsSync10(dir)) return;
|
|
5145
|
-
for (const name of
|
|
5146
|
-
if (SCREENSHOT_FILE.test(name)) rmSync(
|
|
5147
|
-
else if (BROWSER_NAMES.includes(name)) clearScreenshots(
|
|
5244
|
+
for (const name of readdirSync3(dir)) {
|
|
5245
|
+
if (SCREENSHOT_FILE.test(name)) rmSync(join12(dir, name), { force: true });
|
|
5246
|
+
else if (BROWSER_NAMES.includes(name)) clearScreenshots(join12(dir, name));
|
|
5148
5247
|
}
|
|
5149
5248
|
}
|
|
5150
5249
|
function resolveTargets(targets, config, cwd) {
|
|
@@ -5170,25 +5269,26 @@ function projectPath(file, root, basePath = "") {
|
|
|
5170
5269
|
if (isAbsolute(path)) candidates.push(path);
|
|
5171
5270
|
if (path.startsWith("/")) {
|
|
5172
5271
|
const inApp = basePath && path.startsWith(`${basePath}/`) ? path.slice(basePath.length) : path;
|
|
5173
|
-
candidates.push(
|
|
5272
|
+
candidates.push(join12(root, inApp));
|
|
5174
5273
|
} else {
|
|
5175
|
-
candidates.push(
|
|
5274
|
+
candidates.push(join12(root, path));
|
|
5176
5275
|
}
|
|
5177
5276
|
for (const candidate of candidates) {
|
|
5178
|
-
const rel =
|
|
5277
|
+
const rel = relative6(root, candidate);
|
|
5179
5278
|
if (!rel.startsWith("..") && !isAbsolute(rel) && isFile(candidate))
|
|
5180
5279
|
return rel.split(sep3).join("/");
|
|
5181
5280
|
}
|
|
5182
5281
|
return null;
|
|
5183
5282
|
}
|
|
5184
|
-
function relativeSources(routes, root, basePath) {
|
|
5283
|
+
function relativeSources(routes, root, basePath, workspace = "") {
|
|
5284
|
+
const prefix = workspace ? `${workspace.split(sep3).join("/")}/` : "";
|
|
5185
5285
|
for (const route of routes) {
|
|
5186
5286
|
for (const issue of route.issues) {
|
|
5187
5287
|
for (const element of [issue.element, issue.related]) {
|
|
5188
5288
|
const source = element?.source;
|
|
5189
5289
|
if (!element || !source) continue;
|
|
5190
5290
|
const file = projectPath(source.file, root, basePath);
|
|
5191
|
-
element.source = file ? { ...source, file } : null;
|
|
5291
|
+
element.source = file ? { ...source, file: `${prefix}${file}` } : null;
|
|
5192
5292
|
}
|
|
5193
5293
|
}
|
|
5194
5294
|
}
|
|
@@ -5245,11 +5345,11 @@ async function scan(req) {
|
|
|
5245
5345
|
const pageMode = jobs ? jobs.every((j) => !j.url.startsWith("http")) : req.targets.length > 0 && req.targets.every((t) => isPageTarget(t, cwd));
|
|
5246
5346
|
const pageTargets = jobs && pageMode ? jobs : pageMode ? resolveTargets(req.targets, config, cwd) : [];
|
|
5247
5347
|
const outDir = resolve4(cwd, config.outDir ?? ".breakhound");
|
|
5248
|
-
const screenshotDir =
|
|
5348
|
+
const screenshotDir = join12(outDir, "screenshots");
|
|
5249
5349
|
const browsers = config.browsers?.length ? config.browsers : ["chromium"];
|
|
5250
5350
|
const [firstBrowser = "chromium", ...otherBrowsers] = browsers;
|
|
5251
5351
|
ensureBrowsers(browsers);
|
|
5252
|
-
const authFile =
|
|
5352
|
+
const authFile = join12(cwd, AUTH_FILE);
|
|
5253
5353
|
const auth = existsSync10(authFile) ? { storageState: authFile } : {};
|
|
5254
5354
|
const scanner = new Scanner({ ...auth, browser: firstBrowser });
|
|
5255
5355
|
const startedAt = /* @__PURE__ */ new Date();
|
|
@@ -5369,7 +5469,7 @@ async function scan(req) {
|
|
|
5369
5469
|
const result2 = await other.scanPage(route.url, {
|
|
5370
5470
|
...baseOptions,
|
|
5371
5471
|
route: route.route,
|
|
5372
|
-
...baseOptions.screenshotDir ? { screenshotDir:
|
|
5472
|
+
...baseOptions.screenshotDir ? { screenshotDir: join12(baseOptions.screenshotDir, browser) } : {}
|
|
5373
5473
|
});
|
|
5374
5474
|
for (const i of result2.issues)
|
|
5375
5475
|
if (i.screenshot) i.screenshot = `${browser}/${i.screenshot}`;
|
|
@@ -5440,7 +5540,12 @@ async function scan(req) {
|
|
|
5440
5540
|
if (!req.app) await app?.server?.stop();
|
|
5441
5541
|
}
|
|
5442
5542
|
applySeverity(routes, config);
|
|
5443
|
-
relativeSources(
|
|
5543
|
+
relativeSources(
|
|
5544
|
+
routes,
|
|
5545
|
+
app?.project?.root ?? cwd,
|
|
5546
|
+
app?.project?.basePath ?? "",
|
|
5547
|
+
app?.project?.workspace
|
|
5548
|
+
);
|
|
5444
5549
|
const firstUrl = pageTargets[0]?.url;
|
|
5445
5550
|
const baseUrl = app?.baseUrl ?? config.baseUrl ?? (firstUrl?.startsWith("http") ? new URL(firstUrl).origin : null);
|
|
5446
5551
|
const framework = app?.project?.framework;
|
|
@@ -5458,7 +5563,7 @@ async function scan(req) {
|
|
|
5458
5563
|
const kept = req.app ? app : null;
|
|
5459
5564
|
if (req.write === false) return { result, reports: null, app: kept };
|
|
5460
5565
|
const reports = await saveReports(result, config, cwd);
|
|
5461
|
-
const baselineFile =
|
|
5566
|
+
const baselineFile = join12(outDir, BASELINE_FILE);
|
|
5462
5567
|
const earlier = !pageMode && req.targets.length > 0 && existsSync10(baselineFile) ? JSON.parse(readFileSync12(baselineFile, "utf8")) : null;
|
|
5463
5568
|
const baseline = earlier && !earlier.from ? mergeScans(earlier, result) : result;
|
|
5464
5569
|
writeFileSync4(baselineFile, `${JSON.stringify(baseline, null, 2)}
|
|
@@ -5479,7 +5584,7 @@ async function saveExtensionScan(sent, config, cwd) {
|
|
|
5479
5584
|
issues: r.issues.filter((i) => off[i.detector] !== false)
|
|
5480
5585
|
}));
|
|
5481
5586
|
applySeverity(routes, config);
|
|
5482
|
-
relativeSources(routes, project?.root ?? cwd, project?.basePath ?? "");
|
|
5587
|
+
relativeSources(routes, project?.root ?? cwd, project?.basePath ?? "", project?.workspace);
|
|
5483
5588
|
const framework = project?.framework;
|
|
5484
5589
|
const result = createScanResult(routes, {
|
|
5485
5590
|
version: sent.tool.version,
|
|
@@ -5493,9 +5598,9 @@ async function saveExtensionScan(sent, config, cwd) {
|
|
|
5493
5598
|
from: "extension"
|
|
5494
5599
|
});
|
|
5495
5600
|
const outDir = resolve4(cwd, config.outDir ?? ".breakhound");
|
|
5496
|
-
clearScreenshots(
|
|
5601
|
+
clearScreenshots(join12(outDir, "screenshots"));
|
|
5497
5602
|
await saveReports(result, config, cwd);
|
|
5498
|
-
writeFileSync4(
|
|
5603
|
+
writeFileSync4(join12(outDir, BASELINE_FILE), `${JSON.stringify(result, null, 2)}
|
|
5499
5604
|
`);
|
|
5500
5605
|
touchSession(cwd, config.outDir ?? ".breakhound");
|
|
5501
5606
|
return result;
|
|
@@ -5503,14 +5608,14 @@ async function saveExtensionScan(sent, config, cwd) {
|
|
|
5503
5608
|
function saveReports(result, config, cwd) {
|
|
5504
5609
|
const outDir = resolve4(cwd, config.outDir ?? ".breakhound");
|
|
5505
5610
|
return writeReports(result, outDir, {
|
|
5506
|
-
screenshotDir:
|
|
5611
|
+
screenshotDir: join12(outDir, "screenshots"),
|
|
5507
5612
|
pdf: config.pdf ?? false,
|
|
5508
5613
|
verifyCommand: "npx @seybel/breakhound verify"
|
|
5509
5614
|
});
|
|
5510
5615
|
}
|
|
5511
5616
|
function readLastResult(cwd, outDir = ".breakhound") {
|
|
5512
5617
|
const dir = resolve4(cwd, outDir);
|
|
5513
|
-
const file =
|
|
5618
|
+
const file = join12(dir, "result.json");
|
|
5514
5619
|
if (!existsSync10(file)) throw new Error("No scan yet. Run breakhound first.");
|
|
5515
5620
|
return { result: JSON.parse(readFileSync12(file, "utf8")), dir };
|
|
5516
5621
|
}
|
|
@@ -5537,7 +5642,7 @@ function mergeScans(earlier, now) {
|
|
|
5537
5642
|
function readBaseline(cwd, outDir = ".breakhound") {
|
|
5538
5643
|
const dir = resolve5(cwd, outDir);
|
|
5539
5644
|
for (const name of [BASELINE_FILE, "result.json"]) {
|
|
5540
|
-
const file =
|
|
5645
|
+
const file = join13(dir, name);
|
|
5541
5646
|
if (existsSync11(file)) return JSON.parse(readFileSync13(file, "utf8"));
|
|
5542
5647
|
}
|
|
5543
5648
|
throw new Error("Nothing to verify against yet. Run breakhound first to scan the app.");
|
|
@@ -5676,7 +5781,7 @@ async function finishVerify(req, baseline, result) {
|
|
|
5676
5781
|
const outcome = { baselineAt: baseline.startedAt, ...diff, current, reports };
|
|
5677
5782
|
const dir = resolve5(req.cwd, req.config.outDir ?? ".breakhound");
|
|
5678
5783
|
const { current: _, reports: __, ...saved } = outcome;
|
|
5679
|
-
writeFileSync5(
|
|
5784
|
+
writeFileSync5(join13(dir, "verify.json"), `${JSON.stringify(saved, null, 2)}
|
|
5680
5785
|
`);
|
|
5681
5786
|
return outcome;
|
|
5682
5787
|
}
|
|
@@ -5794,12 +5899,12 @@ function pickFiles(cwd, issue, tracked = trackedFiles(cwd)) {
|
|
|
5794
5899
|
const terms = searchTerms(issue);
|
|
5795
5900
|
const scores = /* @__PURE__ */ new Map();
|
|
5796
5901
|
for (const source of [issue.element.source, issue.related?.source]) {
|
|
5797
|
-
if (source?.file && existsSync12(
|
|
5902
|
+
if (source?.file && existsSync12(join14(cwd, source.file))) {
|
|
5798
5903
|
scores.set(source.file, (scores.get(source.file) ?? 0) + 100);
|
|
5799
5904
|
}
|
|
5800
5905
|
}
|
|
5801
5906
|
for (const file of tracked) {
|
|
5802
|
-
const full =
|
|
5907
|
+
const full = join14(cwd, file);
|
|
5803
5908
|
try {
|
|
5804
5909
|
if (statSync3(full).size > MAX_SCAN_BYTES) continue;
|
|
5805
5910
|
const content = readFileSync14(full, "utf8");
|
|
@@ -5808,7 +5913,7 @@ function pickFiles(cwd, issue, tracked = trackedFiles(cwd)) {
|
|
|
5808
5913
|
} catch {
|
|
5809
5914
|
}
|
|
5810
5915
|
}
|
|
5811
|
-
return [...scores.entries()].sort((a, b) => b[1] - a[1]).slice(0, MAX_FILES3).map(([path]) => ({ path, content: excerpt(readFileSync14(
|
|
5916
|
+
return [...scores.entries()].sort((a, b) => b[1] - a[1]).slice(0, MAX_FILES3).map(([path]) => ({ path, content: excerpt(readFileSync14(join14(cwd, path), "utf8"), terms) }));
|
|
5812
5917
|
}
|
|
5813
5918
|
function parseEdits(reply) {
|
|
5814
5919
|
const edits = [];
|
|
@@ -5855,7 +5960,7 @@ function applyEdits(cwd, edits, allowed) {
|
|
|
5855
5960
|
const originals = /* @__PURE__ */ new Map();
|
|
5856
5961
|
for (const edit of edits) {
|
|
5857
5962
|
if (!allowed.has(edit.file)) return { error: `${edit.file} wasn't one of the files given.` };
|
|
5858
|
-
const current = next.get(edit.file) ?? readFileSync14(
|
|
5963
|
+
const current = next.get(edit.file) ?? readFileSync14(join14(cwd, edit.file), "utf8");
|
|
5859
5964
|
if (!originals.has(edit.file)) originals.set(edit.file, current);
|
|
5860
5965
|
const at = locate(current, edit.search);
|
|
5861
5966
|
if (at === "many") {
|
|
@@ -5867,11 +5972,11 @@ function applyEdits(cwd, edits, allowed) {
|
|
|
5867
5972
|
return { error: `The search text for ${edit.file} isn't in the file. Copy it exactly.` };
|
|
5868
5973
|
next.set(edit.file, current.slice(0, at.start) + edit.replace + current.slice(at.end));
|
|
5869
5974
|
}
|
|
5870
|
-
for (const [file, content] of next) writeFileSync6(
|
|
5975
|
+
for (const [file, content] of next) writeFileSync6(join14(cwd, file), content);
|
|
5871
5976
|
return { originals };
|
|
5872
5977
|
}
|
|
5873
5978
|
function restore2(cwd, originals) {
|
|
5874
|
-
for (const [file, content] of originals) writeFileSync6(
|
|
5979
|
+
for (const [file, content] of originals) writeFileSync6(join14(cwd, file), content);
|
|
5875
5980
|
}
|
|
5876
5981
|
function promptFor(group, text2, files, feedback) {
|
|
5877
5982
|
const parts = [text2, "", "Files:", ""];
|
|
@@ -6012,7 +6117,7 @@ async function fix(req) {
|
|
|
6012
6117
|
|
|
6013
6118
|
// src/init.ts
|
|
6014
6119
|
import { existsSync as existsSync13, readFileSync as readFileSync15, writeFileSync as writeFileSync7 } from "fs";
|
|
6015
|
-
import { join as
|
|
6120
|
+
import { join as join15 } from "path";
|
|
6016
6121
|
import { styleText as styleText7 } from "util";
|
|
6017
6122
|
var TEMPLATE = (baseUrl, typed) => `${typed ? "import { defineConfig } from '@seybel/breakhound';\n\nexport default defineConfig({" : "/** @type {import('@seybel/breakhound').BreakhoundConfig} */\nexport default {"}
|
|
6018
6123
|
// breakhound finds your dev server, or starts it, by itself. Set this to
|
|
@@ -6049,7 +6154,7 @@ var JSON_TEMPLATE = `${JSON.stringify(
|
|
|
6049
6154
|
`;
|
|
6050
6155
|
function isInstalled(cwd) {
|
|
6051
6156
|
try {
|
|
6052
|
-
const pkg = JSON.parse(readFileSync15(
|
|
6157
|
+
const pkg = JSON.parse(readFileSync15(join15(cwd, "package.json"), "utf8"));
|
|
6053
6158
|
return Boolean(pkg.dependencies?.breakhound || pkg.devDependencies?.breakhound);
|
|
6054
6159
|
} catch {
|
|
6055
6160
|
return false;
|
|
@@ -6057,7 +6162,7 @@ function isInstalled(cwd) {
|
|
|
6057
6162
|
}
|
|
6058
6163
|
function guessBaseUrl(cwd) {
|
|
6059
6164
|
try {
|
|
6060
|
-
const pkg = JSON.parse(readFileSync15(
|
|
6165
|
+
const pkg = JSON.parse(readFileSync15(join15(cwd, "package.json"), "utf8"));
|
|
6061
6166
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
6062
6167
|
const dev = pkg.scripts?.dev ?? "";
|
|
6063
6168
|
const port = dev.match(/(?:--port|-p)[ =](\d{2,5})/)?.[1];
|
|
@@ -6072,15 +6177,15 @@ function guessBaseUrl(cwd) {
|
|
|
6072
6177
|
return "http://localhost:3000";
|
|
6073
6178
|
}
|
|
6074
6179
|
function runInit(cwd) {
|
|
6075
|
-
const existing = ["ts", "mts", "js", "mjs", "cjs", "json"].map((ext) => `breakhound.config.${ext}`).find((f) => existsSync13(
|
|
6180
|
+
const existing = ["ts", "mts", "js", "mjs", "cjs", "json"].map((ext) => `breakhound.config.${ext}`).find((f) => existsSync13(join15(cwd, f)));
|
|
6076
6181
|
if (existing) {
|
|
6077
6182
|
console.log(`${existing} already exists. Nothing to do.`);
|
|
6078
6183
|
return;
|
|
6079
6184
|
}
|
|
6080
6185
|
const installed2 = isInstalled(cwd);
|
|
6081
|
-
const typed = existsSync13(
|
|
6186
|
+
const typed = existsSync13(join15(cwd, "tsconfig.json"));
|
|
6082
6187
|
const file = !installed2 ? "breakhound.config.json" : typed ? "breakhound.config.ts" : "breakhound.config.mjs";
|
|
6083
|
-
writeFileSync7(
|
|
6188
|
+
writeFileSync7(join15(cwd, file), installed2 ? TEMPLATE(guessBaseUrl(cwd), typed) : JSON_TEMPLATE);
|
|
6084
6189
|
console.log(`${styleText7("green", "\u2714")} Created ${file}`);
|
|
6085
6190
|
if (!installed2) {
|
|
6086
6191
|
console.log(
|
|
@@ -6097,7 +6202,7 @@ Now run ${styleText7("bold", "npx @seybel/breakhound")}.`);
|
|
|
6097
6202
|
|
|
6098
6203
|
// src/mcp.ts
|
|
6099
6204
|
import { existsSync as existsSync14, readFileSync as readFileSync17 } from "fs";
|
|
6100
|
-
import { join as
|
|
6205
|
+
import { join as join17, resolve as resolve6 } from "path";
|
|
6101
6206
|
import { createInterface as createInterface2 } from "readline";
|
|
6102
6207
|
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
6103
6208
|
|
|
@@ -6105,7 +6210,7 @@ import { fileURLToPath as fileURLToPath4 } from "url";
|
|
|
6105
6210
|
import { randomUUID } from "crypto";
|
|
6106
6211
|
import { readFileSync as readFileSync16 } from "fs";
|
|
6107
6212
|
import { createServer } from "http";
|
|
6108
|
-
import { basename, join as
|
|
6213
|
+
import { basename, join as join16 } from "path";
|
|
6109
6214
|
var EXTENSION_IDS = ["ddnmhnjmcokbfabeileblceehjhefkpc"];
|
|
6110
6215
|
var POLL_MS = 25e3;
|
|
6111
6216
|
var PICKUP_MS = 2e4;
|
|
@@ -6130,7 +6235,7 @@ function allowedOrigins() {
|
|
|
6130
6235
|
}
|
|
6131
6236
|
function projectName(root) {
|
|
6132
6237
|
try {
|
|
6133
|
-
const name = JSON.parse(readFileSync16(
|
|
6238
|
+
const name = JSON.parse(readFileSync16(join16(root, "package.json"), "utf8")).name;
|
|
6134
6239
|
if (typeof name === "string" && name) return name;
|
|
6135
6240
|
} catch {
|
|
6136
6241
|
}
|
|
@@ -6550,7 +6655,7 @@ function createTools() {
|
|
|
6550
6655
|
true
|
|
6551
6656
|
);
|
|
6552
6657
|
const out = { content: [{ type: "text", text: found.text }] };
|
|
6553
|
-
const shot = found.issue.screenshot &&
|
|
6658
|
+
const shot = found.issue.screenshot && join17(dir, "screenshots", found.issue.screenshot);
|
|
6554
6659
|
if (shot && existsSync14(shot)) {
|
|
6555
6660
|
out.content.push({
|
|
6556
6661
|
type: "image",
|
|
@@ -6683,7 +6788,7 @@ Run verify to see the issues back.`
|
|
|
6683
6788
|
];
|
|
6684
6789
|
}
|
|
6685
6790
|
function fixedDetails(ctx, routes) {
|
|
6686
|
-
const file =
|
|
6791
|
+
const file = join17(resolve6(ctx.root, ctx.config.outDir ?? ".breakhound"), "verify.json");
|
|
6687
6792
|
if (!existsSync14(file)) return [];
|
|
6688
6793
|
try {
|
|
6689
6794
|
const v = JSON.parse(readFileSync17(file, "utf8"));
|
|
@@ -6754,7 +6859,7 @@ async function runMcpServer(version, io = { input: process.stdin, output: proces
|
|
|
6754
6859
|
"package.json",
|
|
6755
6860
|
"breakhound.config.json",
|
|
6756
6861
|
"breakhound.config.ts"
|
|
6757
|
-
].some((f) => existsSync14(
|
|
6862
|
+
].some((f) => existsSync14(join17(cwd, f)));
|
|
6758
6863
|
if (!looksLikeProject2 && clientCanListRoots) {
|
|
6759
6864
|
try {
|
|
6760
6865
|
const res = await request("roots/list");
|
|
@@ -6901,7 +7006,7 @@ async function runMcpServer(version, io = { input: process.stdin, output: proces
|
|
|
6901
7006
|
import { spawnSync as spawnSync6 } from "child_process";
|
|
6902
7007
|
import { existsSync as existsSync15, mkdirSync as mkdirSync3, readFileSync as readFileSync18, writeFileSync as writeFileSync8 } from "fs";
|
|
6903
7008
|
import { homedir } from "os";
|
|
6904
|
-
import { dirname as dirname5, join as
|
|
7009
|
+
import { dirname as dirname5, join as join18, relative as relative7 } from "path";
|
|
6905
7010
|
import { createInterface as createInterface3 } from "readline/promises";
|
|
6906
7011
|
import { styleText as styleText8 } from "util";
|
|
6907
7012
|
var SERVER_NAME = "breakhound";
|
|
@@ -6967,12 +7072,12 @@ function onPath(command) {
|
|
|
6967
7072
|
function allClients(cwd, home = homedir()) {
|
|
6968
7073
|
const { command, args } = serverCommand();
|
|
6969
7074
|
return [
|
|
6970
|
-
jsonClient("claude", "Claude Code",
|
|
6971
|
-
jsonClient("cursor", "Cursor",
|
|
7075
|
+
jsonClient("claude", "Claude Code", join18(cwd, ".mcp.json"), "mcpServers", { command, args }),
|
|
7076
|
+
jsonClient("cursor", "Cursor", join18(cwd, ".cursor", "mcp.json"), "mcpServers", {
|
|
6972
7077
|
command,
|
|
6973
7078
|
args
|
|
6974
7079
|
}),
|
|
6975
|
-
jsonClient("vscode", "VS Code",
|
|
7080
|
+
jsonClient("vscode", "VS Code", join18(cwd, ".vscode", "mcp.json"), "servers", {
|
|
6976
7081
|
type: "stdio",
|
|
6977
7082
|
command,
|
|
6978
7083
|
args,
|
|
@@ -6984,7 +7089,7 @@ function allClients(cwd, home = homedir()) {
|
|
|
6984
7089
|
jsonClient(
|
|
6985
7090
|
"antigravity",
|
|
6986
7091
|
"Antigravity",
|
|
6987
|
-
|
|
7092
|
+
join18(cwd, ".agents", "mcp_config.json"),
|
|
6988
7093
|
"mcpServers",
|
|
6989
7094
|
{
|
|
6990
7095
|
command,
|
|
@@ -6994,26 +7099,26 @@ function allClients(cwd, home = homedir()) {
|
|
|
6994
7099
|
jsonClient(
|
|
6995
7100
|
"windsurf",
|
|
6996
7101
|
"Windsurf",
|
|
6997
|
-
|
|
7102
|
+
join18(home, ".codeium", "windsurf", "mcp_config.json"),
|
|
6998
7103
|
"mcpServers",
|
|
6999
7104
|
{ command, args }
|
|
7000
7105
|
),
|
|
7001
|
-
codexClient(
|
|
7106
|
+
codexClient(join18(home, ".codex", "config.toml"))
|
|
7002
7107
|
];
|
|
7003
7108
|
}
|
|
7004
7109
|
function detectClients(cwd, home = homedir()) {
|
|
7005
7110
|
const found = {
|
|
7006
|
-
claude: existsSync15(
|
|
7007
|
-
cursor: existsSync15(
|
|
7008
|
-
vscode: existsSync15(
|
|
7009
|
-
antigravity: existsSync15(
|
|
7010
|
-
windsurf: existsSync15(
|
|
7011
|
-
codex: existsSync15(
|
|
7111
|
+
claude: existsSync15(join18(cwd, ".mcp.json")) || existsSync15(join18(cwd, ".claude")) || existsSync15(join18(cwd, "CLAUDE.md")) || existsSync15(join18(home, ".claude")) || onPath("claude"),
|
|
7112
|
+
cursor: existsSync15(join18(cwd, ".cursor")) || existsSync15(join18(home, ".cursor")),
|
|
7113
|
+
vscode: existsSync15(join18(cwd, ".vscode")) || onPath("code"),
|
|
7114
|
+
antigravity: existsSync15(join18(cwd, ".agents", "mcp_config.json")) || existsSync15(join18(home, ".gemini", "config", "mcp_config.json")) || existsSync15(join18(home, ".gemini", "antigravity")) || onPath("antigravity"),
|
|
7115
|
+
windsurf: existsSync15(join18(home, ".codeium", "windsurf")),
|
|
7116
|
+
codex: existsSync15(join18(home, ".codex"))
|
|
7012
7117
|
};
|
|
7013
7118
|
return allClients(cwd, home).filter((c) => found[c.id]);
|
|
7014
7119
|
}
|
|
7015
7120
|
function show(path, cwd) {
|
|
7016
|
-
const rel =
|
|
7121
|
+
const rel = relative7(cwd, path);
|
|
7017
7122
|
return rel.startsWith("..") ? path.replace(homedir(), "~") : rel;
|
|
7018
7123
|
}
|
|
7019
7124
|
async function installMcp(cwd, options = {}) {
|
|
@@ -7279,7 +7384,7 @@ async function main(argv) {
|
|
|
7279
7384
|
}
|
|
7280
7385
|
const outDir = values.out ?? fileConfig.outDir ?? ".breakhound";
|
|
7281
7386
|
if (command === "report") {
|
|
7282
|
-
const file =
|
|
7387
|
+
const file = join19(resolve7(cwd, outDir), "report.html");
|
|
7283
7388
|
if (!existsSync16(file)) throw new Error("No report yet. Run breakhound first.");
|
|
7284
7389
|
openInDefaultApp(file);
|
|
7285
7390
|
console.log(file);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seybel/breakhound",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Find where your layout breaks across every viewport width, then hand the fix to your coding agent.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"responsive",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"playwright",
|
|
12
12
|
"mcp"
|
|
13
13
|
],
|
|
14
|
-
"homepage": "https://github.
|
|
14
|
+
"homepage": "https://seybel.github.io/breakhound/",
|
|
15
15
|
"bugs": {
|
|
16
16
|
"url": "https://github.com/Seybel/breakhound/issues"
|
|
17
17
|
},
|