@weft-js/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +74 -2
  3. package/bin/weft.mjs +8 -0
  4. package/docs/async.md +40 -0
  5. package/docs/browser.md +39 -0
  6. package/docs/config.md +93 -0
  7. package/docs/design.md +97 -0
  8. package/docs/fonts.md +70 -0
  9. package/docs/icons.md +102 -0
  10. package/docs/lists.md +23 -0
  11. package/docs/meta.md +82 -0
  12. package/docs/plugins.md +143 -0
  13. package/docs/props.md +77 -0
  14. package/docs/rules.md +32 -0
  15. package/docs/rust.md +223 -0
  16. package/docs/start-short.md +8 -0
  17. package/docs/start.md +422 -0
  18. package/docs/state.md +96 -0
  19. package/docs/style.md +59 -0
  20. package/docs/surface.md +111 -0
  21. package/docs/testing.md +71 -0
  22. package/docs/ui-state.md +92 -0
  23. package/docs/verify.md +48 -0
  24. package/package.json +39 -4
  25. package/src/add-rust.mjs +123 -0
  26. package/src/add.mjs +358 -0
  27. package/src/agents-md.mjs +134 -0
  28. package/src/alias.mjs +59 -0
  29. package/src/audit.mjs +333 -0
  30. package/src/browser/cli.mjs +34 -0
  31. package/src/browser/client.mjs +132 -0
  32. package/src/browser/commands.mjs +143 -0
  33. package/src/catalog.mjs +479 -0
  34. package/src/check.mjs +328 -0
  35. package/src/cli.mjs +332 -0
  36. package/src/config.d.ts +1 -0
  37. package/src/config.mjs +5 -0
  38. package/src/contributions.mjs +69 -0
  39. package/src/db-layer.mjs +33 -0
  40. package/src/design-checks.mjs +439 -0
  41. package/src/diag.mjs +74 -0
  42. package/src/docs.mjs +131 -0
  43. package/src/doctor.mjs +30 -0
  44. package/src/files.mjs +47 -0
  45. package/src/fix.mjs +40 -0
  46. package/src/gate.mjs +276 -0
  47. package/src/index.mjs +9 -0
  48. package/src/infer/engine.cjs +525 -0
  49. package/src/infer/facts.cjs +252 -0
  50. package/src/infer/hosts.cjs +114 -0
  51. package/src/infer/lsp.mjs +131 -0
  52. package/src/infer/session.cjs +105 -0
  53. package/src/infer/shadow.cjs +46 -0
  54. package/src/infer/worker.cjs +97 -0
  55. package/src/jsx-scan.mjs +86 -0
  56. package/src/load-config.mjs +274 -0
  57. package/src/map.mjs +110 -0
  58. package/src/mcp.mjs +195 -0
  59. package/src/meta-table.mjs +28 -0
  60. package/src/mutate.mjs +140 -0
  61. package/src/plugin-checks.mjs +89 -0
  62. package/src/plugins.mjs +172 -0
  63. package/src/project.mjs +219 -0
  64. package/src/props-checks.mjs +46 -0
  65. package/src/props-listener.mjs +38 -0
  66. package/src/react-checks.mjs +36 -0
  67. package/src/share-checks.mjs +74 -0
  68. package/src/site.mjs +19 -0
  69. package/src/slots.mjs +96 -0
  70. package/src/start.mjs +292 -0
  71. package/src/state-checks.mjs +213 -0
  72. package/src/style.mjs +320 -0
  73. package/src/surface-checks.mjs +218 -0
  74. package/src/tailwind.mjs +22 -0
  75. package/src/tasks.mjs +99 -0
  76. package/src/testdiff.mjs +157 -0
  77. package/src/tests.mjs +572 -0
  78. package/src/text-checks.mjs +172 -0
  79. package/src/tokens.mjs +24 -0
  80. package/src/trust.mjs +282 -0
  81. package/src/weft.mjs +66 -0
  82. package/src/workspace.mjs +105 -0
  83. package/ts-plugin/index.js +137 -0
  84. package/ts-plugin/package.json +1 -0
package/src/audit.mjs ADDED
@@ -0,0 +1,333 @@
1
+ // `weft audit` and the last step of `weft build`: the built pages in a clean headless Chrome, at a phone's width and a
2
+ // desktop's, read the way a crawler, a screen reader and Lighthouse read them. `weft check` reads source; some failures
3
+ // only exist once CSS and a breakpoint have had their say (a button whose text a phone hides has no name), so this
4
+ // looks at the rendered page. No automation library: the DevTools protocol over the WebSocket Node already has.
5
+ //
6
+ // L001–L004 axe-core's rules, the ones Lighthouse grades (names, images, contrast, the rest), L010–L013 console and issues,
7
+ // L020–L025 the head a crawler reads, L030–L031 sideways scroll on a phone and layout shift.
8
+
9
+ import { spawn } from 'node:child_process'
10
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
11
+ import { createServer } from 'node:http'
12
+ import { tmpdir } from 'node:os'
13
+ import { extname, join, relative, resolve, sep } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { findChrome } from './browser/client.mjs'
16
+ import { diagnostic } from './diag.mjs'
17
+
18
+ /** The two widths Lighthouse's defaults stand for: its mobile run (a Moto G Power) and its desktop run. */
19
+ export const FORMS = {
20
+ phone: { width: 412, height: 823, deviceScaleFactor: 1.75, mobile: true },
21
+ desktop: { width: 1350, height: 940, deviceScaleFactor: 1, mobile: false },
22
+ }
23
+
24
+ /**
25
+ * axe-core is what Lighthouse runs for Accessibility and for Agentic Browsing's accessibility tree: the WCAG 2 A and AA
26
+ * rules, and the best-practice ones Lighthouse also grades. `region` (all content in landmarks) Lighthouse leaves out.
27
+ */
28
+ const AXE = {
29
+ runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] },
30
+ rules: { 'heading-order': { enabled: true }, 'empty-heading': { enabled: true }, 'tabindex': { enabled: true }, 'duplicate-id-aria': { enabled: true }, 'image-redundant-alt': { enabled: true }, 'skip-link': { enabled: true }, 'landmark-one-main': { enabled: true }, 'list': { enabled: true }, 'listitem': { enabled: true }, 'region': { enabled: false } },
31
+ resultTypes: ['violations'],
32
+ }
33
+ /** What a rule means in a warp app, where warp is how it usually happens. */
34
+ const WARP = {
35
+ 'heading-order': "warp's titled parts (Accordion, Chart, ActivityFeed) are h3, for a page section under an h2: one placed elsewhere takes `level`, or the place gets the heading it lacks. ",
36
+ }
37
+
38
+ /** axe rules with a code of their own; every other violation is L004, with the rule's id. */
39
+ const RULES = {
40
+ ...Object.fromEntries(['button-name', 'link-name', 'input-button-name', 'label', 'select-name', 'aria-command-name', 'aria-input-field-name', 'aria-toggle-field-name', 'aria-progressbar-name', 'aria-meter-name', 'aria-tooltip-name', 'aria-treeitem-name', 'aria-dialog-name', 'frame-title', 'summary-name'].map(r => [r, 'L001'])),
41
+ ...Object.fromEntries(['image-alt', 'role-img-alt', 'svg-img-alt', 'input-image-alt', 'object-alt', 'area-alt'].map(r => [r, 'L002'])),
42
+ 'color-contrast': 'L003',
43
+ }
44
+ let axe
45
+ const axeSource = () => (axe ??= readFileSync(fileURLToPath(import.meta.resolve('axe-core/axe.min.js')), 'utf8'))
46
+ /** The issue types Lighthouse's `inspector-issues` audit counts (low contrast is axe's, as L003). */
47
+ const ISSUES = new Set(['MixedContentIssue', 'CookieIssue', 'BlockedByResponseIssue', 'HeavyAdIssue', 'ContentSecurityPolicyIssue', 'SharedArrayBufferIssue', 'CorsIssue', 'AttributionReportingIssue', 'QuirksModeIssue', 'FederatedAuthRequestIssue', 'BounceTrackingIssue', 'StylesheetLoadingIssue'])
48
+
49
+ const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml', '.wasm': 'application/wasm', '.webmanifest': 'application/manifest+json' }
50
+
51
+ const fail = (code, message) => Object.assign(new Error(message), { code })
52
+ const sleep = ms => new Promise(r => { setTimeout(r, ms) })
53
+
54
+ /** The public directory a build wrote: the newer of `.output/public` (server presets) and `dist` (Netlify, Vercel static). */
55
+ export function builtPublic(root) {
56
+ const found = ['.output/public', 'dist'].map(d => join(root, d)).filter(d => existsSync(join(d, 'index.html')))
57
+ return found.sort((a, b) => statSync(join(b, 'index.html')).mtimeMs - statSync(join(a, 'index.html')).mtimeMs)[0] ?? null
58
+ }
59
+
60
+ /** Every prerendered page under `dir`, as its URL path. */
61
+ export function pagesIn(dir) {
62
+ const out = []
63
+ const walk = d => {
64
+ for (const e of readdirSync(d, { withFileTypes: true })) {
65
+ if (e.name.startsWith('.') || e.name.startsWith('_')) continue
66
+ const f = join(d, e.name)
67
+ if (e.isDirectory()) walk(f)
68
+ else if (e.name.endsWith('.html') && !/^(404|200|500)\.html$/.test(e.name)) {
69
+ const rel = relative(dir, f).split(sep).join('/')
70
+ out.push(`/${rel.replace(/(^|\/)index\.html$/, '$1').replace(/\.html$/, '')}`)
71
+ }
72
+ }
73
+ }
74
+ walk(dir)
75
+ return out.sort()
76
+ }
77
+
78
+ /**
79
+ * One page for each kind: `/` and each top-level page, then the first page of each section at each depth (the docs'
80
+ * 1,600 pages share a few layouts, and a layout's fault shows on its first page).
81
+ */
82
+ export function samplePages(pages, max = 30) {
83
+ const seen = new Map()
84
+ for (const p of pages) {
85
+ const parts = p.split('/').filter(Boolean)
86
+ const key = parts.length <= 1 ? p : `${parts[0]}/${parts.length}`
87
+ if (!seen.has(key)) seen.set(key, p)
88
+ }
89
+ return [...seen.values()].slice(0, max)
90
+ }
91
+
92
+ /** A static server over a build's public directory, for the audit's Chrome. */
93
+ export function serveStatic(dir) {
94
+ dir = resolve(dir)
95
+ const server = createServer((req, res) => {
96
+ const path = decodeURIComponent(new URL(req.url, 'http://x').pathname)
97
+ const tries = [path, `${path}/index.html`, `${path.replace(/\/$/, '')}.html`].map(p => resolve(dir, `.${p}`))
98
+ const file = tries.find(f => (f === dir || f.startsWith(dir + sep)) && existsSync(f) && statSync(f).isFile())
99
+ const missing = join(dir, '404.html')
100
+ if (!file) {
101
+ res.writeHead(404, { 'content-type': TYPES['.html'] })
102
+ return res.end(existsSync(missing) ? readFileSync(missing) : 'not found')
103
+ }
104
+ res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' })
105
+ res.end(readFileSync(file))
106
+ })
107
+ return new Promise(ok => { server.listen(0, '127.0.0.1', () => ok({ url: `http://127.0.0.1:${server.address().port}`, close: () => server.close() })) })
108
+ }
109
+
110
+ /** A clean headless Chrome (its own empty profile: no extensions, nothing stored) and a DevTools protocol client. As root (a container, CI) Chrome only starts unsandboxed. */
111
+ async function chrome(exe) {
112
+ const profile = mkdtempSync(join(tmpdir(), 'weft-audit-'))
113
+ const child = spawn(exe, [`--user-data-dir=${profile}`, '--headless=new', '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', '--disable-extensions', '--disable-background-networking', '--mute-audio', ...(process.getuid?.() === 0 ? ['--no-sandbox'] : []), 'about:blank'], { stdio: 'ignore' })
114
+ const portFile = join(profile, 'DevToolsActivePort')
115
+ for (let i = 0; !existsSync(portFile) || !readFileSync(portFile, 'utf8').includes('\n'); i++) {
116
+ if (i > 150 || child.exitCode !== null) { child.kill(); throw fail('E_NO_CHROME', `Chrome at ${exe} didn't start`) }
117
+ await sleep(100)
118
+ }
119
+ const [port, path] = readFileSync(portFile, 'utf8').split('\n')
120
+ const ws = new WebSocket(`ws://127.0.0.1:${port}${path}`)
121
+ await new Promise((ok, no) => { ws.onopen = ok; ws.onerror = () => no(fail('E_CDP', 'could not reach Chrome over the DevTools protocol')) })
122
+ let next = 0
123
+ const waiting = new Map()
124
+ const listeners = new Set()
125
+ ws.onmessage = e => {
126
+ const m = JSON.parse(e.data)
127
+ if (m.id !== undefined) {
128
+ const w = waiting.get(m.id)
129
+ waiting.delete(m.id)
130
+ if (m.error) w?.no(fail('E_CDP', m.error.message))
131
+ else w?.ok(m.result)
132
+ } else for (const on of listeners) on(m)
133
+ }
134
+ const send = (method, params = {}, sessionId) => new Promise((ok, no) => {
135
+ const id = ++next
136
+ waiting.set(id, { ok, no })
137
+ ws.send(JSON.stringify({ id, method, params, sessionId }))
138
+ })
139
+ const close = () => {
140
+ try { ws.close() } catch {}
141
+ child.kill()
142
+ // Chrome holds the profile a moment after it's told to go.
143
+ setTimeout(() => rmSync(profile, { recursive: true, force: true }), 500).unref()
144
+ }
145
+ return { send, on: fn => { listeners.add(fn); return () => listeners.delete(fn) }, close }
146
+ }
147
+
148
+ /** In the page: how far the layout moved without a person asking (CLS, summed), and the first elements that moved. */
149
+ const SHIFT = `new Promise(ok => {
150
+ let cls = 0
151
+ const moved = []
152
+ new PerformanceObserver(list => {
153
+ for (const e of list.getEntries()) {
154
+ if (e.hadRecentInput) continue
155
+ cls += e.value
156
+ for (const s of e.sources ?? []) {
157
+ if (!s.node || s.node.nodeType !== 1 || moved.length === 3) continue
158
+ const open = s.node.outerHTML.slice(0, s.node.outerHTML.indexOf('>') + 1)
159
+ moved.push(open.length > 120 ? open.slice(0, 117) + '…>' : open)
160
+ }
161
+ }
162
+ }).observe({ type: 'layout-shift', buffered: true })
163
+ setTimeout(() => ok({ cls: Math.round(cls * 1000) / 1000, moved }), 50)
164
+ })`
165
+
166
+ /** In the page: what a crawler reads from the head, and what pokes past a phone's right edge. */
167
+ const HEAD = `(() => {
168
+ const meta = n => document.querySelector('meta[name="' + n + '"]')?.getAttribute('content')?.trim() ?? null
169
+ const wide = []
170
+ const edge = innerWidth + 1
171
+ if (document.documentElement.scrollWidth > edge) {
172
+ for (const el of document.body.querySelectorAll('*')) {
173
+ const r = el.getBoundingClientRect()
174
+ if (r.right > edge && r.width > 0 && getComputedStyle(el).position !== 'fixed' && !(el.parentElement && el.parentElement.getBoundingClientRect().right > edge)) {
175
+ const open = el.outerHTML.slice(0, el.outerHTML.indexOf('>') + 1)
176
+ wide.push(open.length > 120 ? open.slice(0, 117) + '…>' : open)
177
+ if (wide.length === 3) break
178
+ }
179
+ }
180
+ }
181
+ return {
182
+ title: document.title.trim(), lang: document.documentElement.getAttribute('lang') ?? '', description: meta('description'),
183
+ viewport: meta('viewport'), robots: meta('robots'), scroll: document.documentElement.scrollWidth - innerWidth, wide,
184
+ }
185
+ })()`
186
+
187
+ /** Audit one page in one form factor: what Chrome said while it loaded, its head, its layout, and axe-core's rules. */
188
+ async function auditPage(browser, url, form, statics) {
189
+ const { targetId } = await browser.send('Target.createTarget', { url: 'about:blank' })
190
+ const { sessionId } = await browser.send('Target.attachToTarget', { targetId, flatten: true })
191
+ const send = (m, p) => browser.send(m, p, sessionId)
192
+ const found = []
193
+ let document = null
194
+ let loaded
195
+ const load = new Promise(ok => { loaded = ok })
196
+ const where = (u, line) => (u ? `${u.replace(/^https?:\/\/127\.0\.0\.1:\d+/, '')}${line === undefined ? '' : `:${line + 1}`}` : '')
197
+ const off = browser.on(m => {
198
+ if (m.sessionId !== sessionId) return
199
+ const p = m.params
200
+ if (m.method === 'Page.loadEventFired') loaded()
201
+ else if (m.method === 'Network.responseReceived' && p.type === 'Document' && !document) document = p.response
202
+ else if (m.method === 'Runtime.exceptionThrown') {
203
+ const d = p.exceptionDetails
204
+ found.push({ code: 'L010', key: d.exception?.description?.split('\n')[0] ?? d.text, at: where(d.url, d.lineNumber), text: d.exception?.description?.split('\n')[0] ?? d.text })
205
+ } else if (m.method === 'Runtime.consoleAPICalled' && (p.type === 'error' || p.type === 'assert')) {
206
+ const text = p.args.map(a => a.value ?? a.description ?? '').join(' ').split('\n')[0]
207
+ const top = p.stackTrace?.callFrames?.[0]
208
+ found.push({ code: 'L010', key: text, at: where(top?.url, top?.lineNumber), text })
209
+ } else if (m.method === 'Log.entryAdded' && p.entry.level === 'error') {
210
+ // a build's pages served as files have no server: its realtime and function routes answer 404 here and nowhere else
211
+ if (statics && /\/_weft\/(rt|ws|fn|data|m|x)\b/.test(`${p.entry.url ?? ''} ${p.entry.text}`)) return
212
+ found.push({ code: 'L011', key: `${p.entry.url ?? ''} ${p.entry.text}`, at: where(p.entry.url), text: p.entry.text })
213
+ } else if (m.method === 'Audits.issueAdded') {
214
+ const { code, details } = p.issue
215
+ if (code === 'DeprecationIssue') {
216
+ const d = details.deprecationIssueDetails
217
+ found.push({ code: 'L012', key: d.type, at: where(d.sourceCodeLocation?.url, d.sourceCodeLocation?.lineNumber), text: d.type })
218
+ } else if (ISSUES.has(code)) found.push({ code: 'L013', key: code, text: code.replace(/Issue$/, '').replace(/([a-z])([A-Z])/g, '$1 $2') })
219
+ }
220
+ })
221
+ try {
222
+ const f = FORMS[form]
223
+ await Promise.all(['Page.enable', 'Runtime.enable', 'Log.enable', 'Network.enable', 'Audits.enable'].map(m => send(m)))
224
+ await send('Emulation.setDeviceMetricsOverride', { width: f.width, height: f.height, deviceScaleFactor: f.deviceScaleFactor, mobile: f.mobile })
225
+ await send('Emulation.setTouchEmulationEnabled', { enabled: f.mobile, maxTouchPoints: f.mobile ? 5 : 1 })
226
+ await send('Page.navigate', { url })
227
+ await Promise.race([load, sleep(15_000)])
228
+ await sleep(800) // hydration's errors, and a font swap's shift, land after load
229
+ if (document && document.status >= 400) found.push({ code: 'L025', key: String(document.status), text: `HTTP ${document.status}` })
230
+ const header = Object.entries(document?.headers ?? {}).find(([k]) => k.toLowerCase() === 'x-robots-tag')?.[1]
231
+ const head = (await send('Runtime.evaluate', { expression: HEAD, returnByValue: true })).result.value
232
+ if (!head.title) found.push({ code: 'L020', key: 'title' })
233
+ if (!head.description) found.push({ code: 'L021', key: 'description' })
234
+ if (!head.lang) found.push({ code: 'L022', key: 'lang' })
235
+ if (!head.viewport || !/width=device-width/.test(head.viewport)) found.push({ code: 'L023', key: 'viewport' })
236
+ const noindex = [header && /noindex|none/i.test(header) && `the \`x-robots-tag: ${header}\` header`, head.robots && /noindex|none/i.test(head.robots) && `<meta name="robots" content="${head.robots}">`].filter(Boolean)
237
+ if (noindex.length) found.push({ code: 'L024', key: noindex.join(), text: noindex.join(' and '), header: !!header })
238
+ if (f.mobile && head.scroll > 0) found.push({ code: 'L030', key: head.wide.join() || 'page', text: `${head.scroll}px`, wide: head.wide })
239
+ const shift = (await send('Runtime.evaluate', { expression: SHIFT, awaitPromise: true, returnByValue: true })).result.value
240
+ if (shift.cls > 0.05) found.push({ code: 'L031', key: shift.moved.join() || 'page', cls: shift.cls, moved: shift.moved })
241
+ await send('Runtime.evaluate', { expression: axeSource() })
242
+ const { result, exceptionDetails } = await send('Runtime.evaluate', { expression: `axe.run(document, ${JSON.stringify(AXE)}).then(r => r.violations)`, awaitPromise: true, returnByValue: true })
243
+ if (exceptionDetails) throw fail('E_AUDIT', `axe-core failed on ${url}: ${exceptionDetails.exception?.description ?? exceptionDetails.text}`)
244
+ for (const v of result.value) {
245
+ for (const n of v.nodes) {
246
+ const tag = n.html.slice(0, n.html.indexOf('>') + 1)
247
+ found.push({ code: RULES[v.id] ?? 'L004', key: `${v.id} ${n.target.join(' ')}`, rule: v.id, help: v.help, fix: n.failureSummary?.replace(/^Fix (any|all) of the following:\s*/, '').split('\n')[0].trim(), selector: n.target.join(' '), tag: tag.length > 160 ? `${tag.slice(0, 157)}…>` : tag, contrast: v.id === 'color-contrast' ? n.any[0]?.data : undefined })
248
+ }
249
+ }
250
+ return found
251
+ } finally {
252
+ off()
253
+ await browser.send('Target.closeTarget', { targetId }).catch(() => {})
254
+ }
255
+ }
256
+
257
+ const say = {
258
+ L001: f => `${f.tag} (\`${f.selector}\`) has no accessible name (${f.rule}): a screen reader announces only its role. When the source has text, CSS hid it at this width.`,
259
+ L002: f => `${f.tag} (\`${f.selector}\`) is an image with no text alternative (${f.rule}): a screen reader reads its file name.`,
260
+ L003: f => `${f.tag} (\`${f.selector}\`): ${f.contrast ? `${f.contrast.fgColor} on ${f.contrast.bgColor} is ${f.contrast.contrastRatio}:1 at ${f.contrast.fontSize}, under ${f.contrast.expectedContrastRatio}` : 'its text contrast is too low'}.`,
261
+ L004: f => `${f.tag} (\`${f.selector}\`): ${f.help} (axe \`${f.rule}\`).`,
262
+ L031: f => `The layout moved by ${f.cls} (CLS) while the page loaded, with no one touching it${f.moved.length ? `: ${f.moved.join(', ')} moved` : ''}. Over 0.1 costs Lighthouse's Performance score.`,
263
+ L010: f => `Console error${f.at ? ` at ${f.at}` : ''}: ${f.text}`,
264
+ L011: f => (f.at === '/favicon.ico' ? 'The page names no icon, so the browser asked for /favicon.ico and got a 404: Lighthouse counts it as a console error. Add `<link rel="icon">` through `site.head`, or a `public/favicon.ico`.' : `The browser logged an error${f.at ? ` for ${f.at}` : ''}: ${f.text}`),
265
+ L012: f => `A deprecated browser API is in use${f.at ? ` at ${f.at}` : ''}: ${f.text}. Lighthouse marks Best Practices down for it.`,
266
+ L013: f => `Chrome logged a ${f.text} issue for the page. Lighthouse marks Best Practices down for it.`,
267
+ L020: () => 'The page has no `<title>`: search results and tabs show its URL.',
268
+ L021: () => 'The page has no meta description: search results make one up from the page.',
269
+ L022: () => '`<html>` has no `lang`: screen readers guess the language, and translation offers go wrong.',
270
+ L023: () => 'No `<meta name="viewport" content="width=device-width, initial-scale=1">`: a phone renders the page at 980px and shrinks it.',
271
+ L024: f => `Search engines are told not to index this page, by ${f.text}.${f.header ? ' Hosts add this header to preview and permalink URLs (Netlify, Vercel): on the production URL it is gone.' : ''}`,
272
+ L025: f => `The page answered ${f.text}.`,
273
+ L030: f => `The page scrolls sideways on a phone: it is ${f.text} wider than the screen.${f.wide?.length ? ` Past the edge: ${f.wide.join(', ')}` : ''}`,
274
+ }
275
+ /** A page may ask not to be indexed, and a host's preview URL does on its behalf: L024 says so without failing. */
276
+ const SEVERITY = { L021: 'warning', L024: 'warning', L030: 'warning', L031: 'warning' }
277
+
278
+ /**
279
+ * @param {string} root project root
280
+ * @param {{ url?: string, dir?: string, routes?: string[], all?: boolean, max?: number, forms?: (keyof FORMS)[], concurrency?: number }} [options]
281
+ * `url`: audit a running site (a deploy, `weft start`); else `dir`, a build's public directory (found when left out)
282
+ * @returns {Promise<{ ok: boolean, skipped?: string, base: string, pages: string[], diagnostics: object[], ms: number }>}
283
+ */
284
+ export async function audit(root, options = {}) {
285
+ const t = performance.now()
286
+ const exe = findChrome()
287
+ const forms = options.forms ?? ['phone', 'desktop']
288
+ const skip = why => ({ ok: true, skipped: why, base: options.url ?? '', pages: [], diagnostics: [diagnostic({ code: 'L000', severity: 'warning', file: 'weft.config.ts', why })], ms: 0 })
289
+ if (!exe) return skip('No Chrome or Chromium here, so the built pages were not audited (L001–L030). Set `WEFT_CHROME`, or run `weft audit --url <deploy>` where there is one.')
290
+ let base = options.url?.replace(/\/$/, '')
291
+ let server
292
+ let routes = options.routes
293
+ if (!base) {
294
+ const dir = options.dir ?? builtPublic(root)
295
+ if (!dir) return skip('No built pages to audit: run `weft build` first, or pass `--url`.')
296
+ const pages = pagesIn(dir)
297
+ if (!pages.length) return skip(`No prerendered pages in ${relative(root, dir)} to audit: pass \`--url\` to audit a running server.`)
298
+ routes ??= options.all ? pages : samplePages(pages, options.max)
299
+ server = await serveStatic(dir)
300
+ base = server.url
301
+ }
302
+ routes ??= ['/']
303
+ const browser = await chrome(exe)
304
+ const findings = new Map()
305
+ try {
306
+ const jobs = routes.flatMap(route => forms.map(form => ({ route, form })))
307
+ let i = 0
308
+ const worker = async () => {
309
+ for (let job; (job = jobs[i++]);) {
310
+ for (const f of await auditPage(browser, `${base}${job.route}`, job.form, !!server)) {
311
+ const key = `${f.code} ${f.key}`
312
+ const seen = findings.get(key) ?? { ...f, routes: new Set(), forms: new Set() }
313
+ seen.routes.add(job.route)
314
+ seen.forms.add(job.form)
315
+ findings.set(key, seen)
316
+ }
317
+ }
318
+ }
319
+ await Promise.all(Array.from({ length: Math.min(options.concurrency ?? 4, jobs.length) }, worker))
320
+ } finally {
321
+ browser.close()
322
+ server?.close()
323
+ }
324
+ const diagnostics = [...findings.values()].map(f => {
325
+ const pages = routes.filter(r => f.routes.has(r))
326
+ const on = `${forms.filter(x => f.forms.has(x)).join(' and ')}, ${pages.length === 1 ? pages[0] : `${pages.length} pages (${pages.slice(0, 3).join(', ')}${pages.length > 3 ? ', …' : ''})`}`
327
+ const severity = f.code === 'L031' && f.cls > 0.1 ? 'error' : SEVERITY[f.code] ?? 'error'
328
+ // an axe rule outside L001–L003 carries its own fix: the rule's first remedy, and its page
329
+ const help = f.code === 'L004' ? `${f.fix ? `${f.fix}. ` : ''}${WARP[f.rule] ?? ''}The rule: https://dequeuniversity.com/rules/axe/4.14/${f.rule}` : undefined
330
+ return diagnostic({ code: f.code, severity, file: pages[0], why: `${say[f.code](f)} (${on})`, help })
331
+ }).sort((a, b) => (a.severity === b.severity ? a.code.localeCompare(b.code) : a.severity === 'error' ? -1 : 1))
332
+ return { ok: !diagnostics.some(d => d.severity === 'error'), base: options.url ?? base, pages: routes, diagnostics, ms: Math.round(performance.now() - t) }
333
+ }
@@ -0,0 +1,34 @@
1
+ // `weft browser <command>`: JSON on stdout with --json, a readable form on stderr otherwise.
2
+
3
+ import { browser, browserUsage, cliParams } from './commands.mjs'
4
+
5
+ export { browserUsage }
6
+
7
+ export async function browserCommand(args, { print, say, io }) {
8
+ const [cmd, ...words] = args.positional
9
+ if (!cmd || cmd === 'help') {
10
+ say(browserUsage)
11
+ return cmd ? 0 : 2
12
+ }
13
+ try {
14
+ const result = await browser(args.root, cmd, cliParams(cmd, words, args.flags), { url: args.flags.url, tab: args.flags.tab })
15
+ if (args.json) print(result)
16
+ else io.stdout.write(`${human(cmd, result)}\n`)
17
+ return result?.ok === false ? 1 : 0
18
+ } catch (error) {
19
+ if (error.code === 'E_USAGE') throw error
20
+ if (args.json) print({ ok: false, error: { code: error.code ?? 'E_INTERNAL', message: error.message, tabs: error.tabs } })
21
+ say(`weft browser ${cmd}: ${error.message}`)
22
+ return 1
23
+ }
24
+ }
25
+
26
+ function human(cmd, r) {
27
+ const text = v => (typeof v === 'string' ? v : v?.path ? `(${v.chars} chars in ${v.path})\n${v.head}` : '')
28
+ if (cmd === 'snapshot') return [`${r.title} ${r.url}`, r.delta ?? text(r.tree), r.graph ? `\n# graph\n${text(r.graph)}` : '', r.errors?.length ? `\n# errors\n${r.errors.map(e => `${e.message}${e.at ? ` (at ${e.at})` : ''}`).join('\n')}` : ''].filter(Boolean).join('\n')
29
+ if (cmd === 'act') return `${r.target}: ${r.changes}`
30
+ if (cmd === 'assert') return `${r.ok ? 'ok' : 'FAIL'} ${r.target} ${JSON.stringify(r.expected)}${r.ok ? '' : `, got ${JSON.stringify(r.actual)}`}`
31
+ if (cmd === 'graph') return text(r.graph)
32
+ if (cmd === 'record' && r.code) return r.code
33
+ return JSON.stringify(r, null, 2)
34
+ }
@@ -0,0 +1,132 @@
1
+ // The `weft browser` transport: commands go to the dev server, which relays them to an open tab of the app (@weft-js/core/agent
2
+ // in the page). No browser automation library: the page is whatever browser the agent or person already has open. When
3
+ // there is none (CI, a sandbox), `launch` starts a plain headless Chrome on the app as a host page, and `screenshot`
4
+ // reads it over the DevTools protocol.
5
+
6
+ import { spawn } from 'node:child_process'
7
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import { loadConfig } from '../load-config.mjs'
10
+
11
+ /** The dev server: `--url`, else what the weft() plugin wrote to `.weft/dev.json`, else `environments.dev.url`. */
12
+ export async function devUrl(root, given) {
13
+ if (given) return given.replace(/\/$/, '')
14
+ const file = join(root, '.weft', 'dev.json')
15
+ if (existsSync(file)) {
16
+ try { return JSON.parse(readFileSync(file, 'utf8')).url.replace(/\/$/, '') } catch {}
17
+ }
18
+ const { config } = await loadConfig(root).catch(() => ({ config: {} }))
19
+ return (config.environments?.dev?.url ?? 'http://localhost:5173').replace(/\/$/, '')
20
+ }
21
+
22
+ const fail = (code, message, extra) => Object.assign(new Error(message), { code, ...extra })
23
+
24
+ async function request(url, init) {
25
+ try {
26
+ const res = await fetch(`${url}/__weft/agent`, { ...init, headers: { 'x-weft-agent': '1', 'content-type': 'application/json' } })
27
+ if (res.status === 404) throw fail('E_NO_BRIDGE', `${url} is not a weft dev server (no /__weft/agent): serve the app with the weft() Vite plugin`)
28
+ return await res.json()
29
+ } catch (e) {
30
+ if (e.code) throw e
31
+ throw fail('E_NO_DEV', `no dev server answers at ${url}: start it (vite), or pass --url`)
32
+ }
33
+ }
34
+
35
+ /** Run one command in a tab (the focused one, else the most recent). */
36
+ export async function call(root, cmd, args = {}, { url, tab, timeout } = {}) {
37
+ const base = await devUrl(root, url)
38
+ const out = await request(base, { method: 'POST', body: JSON.stringify({ cmd, args, tab, timeout }) })
39
+ if (!out.ok) throw fail(out.code ?? 'E_PAGE', out.error, { tabs: out.tabs })
40
+ return out.result
41
+ }
42
+
43
+ export async function tabs(root, { url } = {}) {
44
+ const base = await devUrl(root, url)
45
+ const out = await request(base, { method: 'GET' })
46
+ return { url: base, agent: out.agent, tabs: out.tabs }
47
+ }
48
+
49
+ /** Wait until a tab is connected (after `launch` or `navigate`). */
50
+ export async function waitForTab(root, { url, ms = 15_000 } = {}) {
51
+ const until = Date.now() + ms
52
+ for (;;) {
53
+ try {
54
+ const t = await tabs(root, { url })
55
+ if (t.tabs.length) return t
56
+ } catch (e) { if (Date.now() > until) throw e }
57
+ if (Date.now() > until) throw fail('E_NO_TAB', 'no tab connected in time')
58
+ await new Promise(r => { setTimeout(r, 150) })
59
+ }
60
+ }
61
+
62
+ // ---- a host page when the agent has no browser ------------------------------------------------------------------
63
+
64
+ const chromes = {
65
+ darwin: ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
66
+ linux: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'],
67
+ win32: ['C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'],
68
+ }
69
+
70
+ export function findChrome() {
71
+ const env = process.env.WEFT_CHROME ?? process.env.CHROME_PATH
72
+ if (env && existsSync(env)) return env
73
+ return (chromes[process.platform] ?? []).find(p => existsSync(p)) ?? null
74
+ }
75
+
76
+ const stateFile = root => join(root, '.weft', 'browser', 'chrome.json')
77
+
78
+ /** Start a headless Chrome on `route` (a plain page; @weft-js/core/agent in it connects back to the dev server). */
79
+ export async function launch(root, route = '/', { url, headed = false } = {}) {
80
+ const exe = findChrome()
81
+ if (!exe) throw fail('E_NO_CHROME', 'no Chrome or Chromium found; open the app in your own browser instead, or set WEFT_CHROME')
82
+ close(root)
83
+ const base = await devUrl(root, url)
84
+ const profile = join(root, '.weft', 'browser', 'profile')
85
+ mkdirSync(profile, { recursive: true })
86
+ const args = [`--user-data-dir=${profile}`, '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ...(headed ? [] : ['--headless=new']), `${base}${route.startsWith('/') ? route : `/${route}`}`]
87
+ const child = spawn(exe, args, { detached: true, stdio: 'ignore' })
88
+ child.unref()
89
+ writeFileSync(stateFile(root), JSON.stringify({ pid: child.pid, profile, exe }))
90
+ const t = await waitForTab(root, { url })
91
+ return { launched: true, pid: child.pid, headless: !headed, ...t }
92
+ }
93
+
94
+ export function close(root) {
95
+ const file = stateFile(root)
96
+ if (!existsSync(file)) return { closed: false }
97
+ const { pid } = JSON.parse(readFileSync(file, 'utf8'))
98
+ try { process.kill(pid) } catch {}
99
+ rmSync(file, { force: true })
100
+ return { closed: true, pid }
101
+ }
102
+
103
+ /** A PNG of the launched Chrome's page (DevTools protocol). An agent's own browser takes its own screenshots. */
104
+ export async function screenshot(root, out) {
105
+ const file = stateFile(root)
106
+ if (!existsSync(file)) throw fail('E_NO_CHROME', 'screenshot needs `weft browser launch`; in your own browser, use its screenshot tool')
107
+ const { profile } = JSON.parse(readFileSync(file, 'utf8'))
108
+ const [port] = readFileSync(join(profile, 'DevToolsActivePort'), 'utf8').split('\n')
109
+ const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json()
110
+ const page = targets.find(t => t.type === 'page')
111
+ if (!page) throw fail('E_NO_TAB', 'the launched Chrome has no page')
112
+ const ws = new WebSocket(page.webSocketDebuggerUrl)
113
+ await new Promise((ok, no) => { ws.onopen = ok; ws.onerror = () => no(fail('E_CDP', 'could not reach the page over the DevTools protocol')) })
114
+ const data = await new Promise((ok, no) => {
115
+ ws.onmessage = e => { const m = JSON.parse(e.data); if (m.id !== 1) return; if (m.error) no(fail('E_CDP', m.error.message)); else ok(m.result.data) }
116
+ ws.send(JSON.stringify({ id: 1, method: 'Page.captureScreenshot', params: { format: 'png' } }))
117
+ })
118
+ ws.close()
119
+ const path = out ?? join(root, '.weft', 'browser', `screenshot-${Date.now()}.png`)
120
+ mkdirSync(join(path, '..'), { recursive: true })
121
+ writeFileSync(path, Buffer.from(data, 'base64'))
122
+ return { path }
123
+ }
124
+
125
+ /** Big payloads go to a file; the reply holds the path (research 05: snapshots pile up in context). */
126
+ export function spill(root, name, text, limit = 8000) {
127
+ if (typeof text !== 'string' || text.length <= limit) return text
128
+ const path = join(root, '.weft', 'browser', `${name}-${Date.now()}.txt`)
129
+ mkdirSync(join(path, '..'), { recursive: true })
130
+ writeFileSync(path, text)
131
+ return { path, chars: text.length, head: `${text.slice(0, 1200)}\n…` }
132
+ }
@@ -0,0 +1,143 @@
1
+ // `weft browser` commands, shared by the CLI and the `weft mcp` browser tools. Each takes structured params and
2
+ // returns JSON; the page side is @weft-js/core/agent (packages/weft/src/agent.ts).
3
+
4
+ import { mkdirSync, writeFileSync } from 'node:fs'
5
+ import { dirname, isAbsolute, join } from 'node:path'
6
+ import { call, close, launch, screenshot, spill, tabs, waitForTab } from './client.mjs'
7
+
8
+ export const actions = ['click', 'dblclick', 'hover', 'focus', 'clear', 'fill', 'type', 'press', 'select', 'check', 'uncheck']
9
+ export const expectations = ['visible', 'absent', 'text', 'exactText', 'value', 'checked', 'unchecked', 'disabled', 'enabled', 'focused', 'count']
10
+
11
+ const sleep = ms => new Promise(r => { setTimeout(r, ms) })
12
+
13
+ /**
14
+ * @param {string} root project root
15
+ * @param {string} cmd
16
+ * @param {Record<string, any>} p params
17
+ * @param {{ url?: string, tab?: string }} [o] dev server URL and tab
18
+ */
19
+ export async function browser(root, cmd, p = {}, o = {}) {
20
+ const page = (name, args, extra) => call(root, name, args, { ...o, ...extra })
21
+ switch (cmd) {
22
+ case 'tabs': return tabs(root, o)
23
+ case 'launch': return launch(root, p.route ?? '/', { url: o.url, headed: p.headed })
24
+ case 'close': return close(root)
25
+ case 'open': {
26
+ const before = await page('info', {})
27
+ await page('navigate', { to: p.route ?? '/' })
28
+ // Same tab id after the load (@weft-js/core/agent keeps it in sessionStorage); wait for the new document to say hello.
29
+ const until = Date.now() + 15_000
30
+ do {
31
+ await sleep(150)
32
+ try {
33
+ const info = await page('info', {}, { timeout: 2000 })
34
+ if (info.url !== before.url || p.route === new URL(before.url).pathname) return info
35
+ } catch {}
36
+ } while (Date.now() <= until)
37
+ return waitForTab(root, o)
38
+ }
39
+ case 'snapshot': {
40
+ const out = await page('snapshot', { interactive: p.interactive, scope: p.scope, delta: p.delta, graph: p.graph })
41
+ if (!p.full) {
42
+ if (out.tree) out.tree = spill(root, 'snapshot', out.tree)
43
+ if (out.graph) out.graph = spill(root, 'graph', out.graph)
44
+ }
45
+ return out
46
+ }
47
+ case 'act': return page('act', { target: p.target, action: p.action, value: p.value })
48
+ case 'assert': return page('assert', { target: p.target, expect: p.expect, timeout: p.timeout })
49
+ case 'graph': {
50
+ const out = await page('graph', { filter: p.filter })
51
+ return { graph: p.full ? out.graph : spill(root, 'graph', out.graph), nodes: out.nodes.length }
52
+ }
53
+ case 'set': return page('set', { name: p.name, value: p.value })
54
+ case 'time': return page('time', { op: p.op, ms: p.ms, reload: p.reload })
55
+ case 'net': return page('net', { op: p.op, ms: p.ms })
56
+ case 'record': {
57
+ const out = await page('record', { op: p.op, text: p.text, component: p.component, from: p.from, props: p.props, imports: p.imports, name: p.name })
58
+ if (p.op === 'code' && p.out) {
59
+ const file = isAbsolute(p.out) ? p.out : join(root, p.out)
60
+ mkdirSync(dirname(file), { recursive: true })
61
+ writeFileSync(file, out.code)
62
+ return { ...out, written: file }
63
+ }
64
+ return out
65
+ }
66
+ case 'logs': return page('logs', {})
67
+ case 'overlay': return page('overlay', { show: p.show })
68
+ case 'info': return page('info', {})
69
+ case 'reload': return page('reload', {})
70
+ case 'screenshot': return screenshot(root, p.out && (isAbsolute(p.out) ? p.out : join(root, p.out)))
71
+ default:
72
+ throw Object.assign(new Error(`unknown browser command ${cmd}`), { code: 'E_USAGE' })
73
+ }
74
+ }
75
+
76
+ /** `button "Add Banana"`, `label Coupon`, `text Cart`, `@e5` → a target, and how many words it took. */
77
+ export function parseTarget(words) {
78
+ const [a, b] = words
79
+ if (!a) throw Object.assign(new Error('missing target: <role> <name>, label <text>, text <text>, or @ref'), { code: 'E_USAGE' })
80
+ if (a.startsWith('@')) return [a, 1]
81
+ if (a === 'label') return [{ label: b }, 2]
82
+ if (a === 'text') return [{ text: b }, 2]
83
+ return [b === undefined || b === '-' ? { role: a } : { role: a, name: b }, b === undefined ? 1 : 2]
84
+ }
85
+
86
+ /** CLI words → params. */
87
+ export function cliParams(cmd, words, flags) {
88
+ const num = v => (v === undefined ? undefined : Number(v))
89
+ switch (cmd) {
90
+ case 'launch': case 'open': return { route: words[0] ?? '/', headed: !!flags.headed }
91
+ case 'snapshot': return { interactive: !!flags.interactive, delta: !!flags.delta, graph: flags['graph-only'] ? 'only' : flags['no-graph'] ? false : flags.graph ?? true, scope: flags.scope ? parseScope(flags.scope) : undefined, full: !!flags.full }
92
+ case 'act': {
93
+ const [target, n] = parseTarget(words)
94
+ const action = words[n] ?? 'click'
95
+ if (!actions.includes(action)) throw Object.assign(new Error(`unknown action ${action}: ${actions.join(', ')}`), { code: 'E_USAGE' })
96
+ return { target, action, value: words[n + 1] }
97
+ }
98
+ case 'assert': {
99
+ const [target, n] = parseTarget(words)
100
+ const kind = words[n] ?? 'visible'
101
+ if (!expectations.includes(kind)) throw Object.assign(new Error(`unknown expectation ${kind}: ${expectations.join(', ')}`), { code: 'E_USAGE' })
102
+ const v = words[n + 1]
103
+ return { target, expect: { [kind]: kind === 'count' ? Number(v) : ['text', 'exactText', 'value'].includes(kind) ? v ?? '' : true }, timeout: num(flags.timeout) }
104
+ }
105
+ case 'graph': return { filter: words[0], full: !!flags.full }
106
+ case 'set': {
107
+ let value = words[1]
108
+ try { value = JSON.parse(words[1]) } catch {}
109
+ return { name: words[0], value }
110
+ }
111
+ case 'time': return { op: words[0] ?? 'status', ms: num(words[1]), reload: !!flags.reload }
112
+ case 'net': return { op: words[0] ?? 'status', ms: num(words[1]) }
113
+ case 'record': return { op: words[0] ?? 'code', text: words.slice(1).join(' '), component: flags.component, from: flags.from, props: flags.props, name: flags.name, out: flags.out }
114
+ case 'overlay': return { show: words[0] === undefined ? undefined : words[0] !== 'off' }
115
+ case 'screenshot': return { out: flags.out }
116
+ default: return {}
117
+ }
118
+ }
119
+
120
+ const parseScope = s => (s.startsWith('@') ? s : (() => { const i = s.indexOf(':'); return i < 0 ? { role: s } : { role: s.slice(0, i), name: s.slice(i + 1) } })())
121
+
122
+ export const browserUsage = `usage: weft browser <command> [--json] [--url <dev server>] [--tab <id>]
123
+
124
+ The page is the app open in any browser (yours, an agent's, or \`launch\`); the dev server relays to it.
125
+ In a browser that runs JS, the same commands are \`await __weft__.agent.run('<command>', args)\`.
126
+
127
+ tabs tabs connected to the dev server
128
+ launch [route] [--headed] start a headless Chrome on the app (when you have no browser)
129
+ open <route> navigate the tab
130
+ snapshot [--interactive] [--delta] [--scope role:name|@ref] [--graph-only|--no-graph] [--full]
131
+ roles, names and refs, plus the reactive graph (Cart.total = 42.5 ← …)
132
+ act <role> <name> <action> [value] click dblclick hover focus clear fill type press select check uncheck
133
+ act @e5 <action> [value] (or: act label <text> …, act text <text> …)
134
+ assert <target> <expectation> [v] visible absent text exactText value checked unchecked disabled enabled focused count
135
+ graph [filter] the named reactive graph
136
+ set <Name.box> <json> write state by its graph name (dev only)
137
+ time pause [--reload]|advance <ms>|resume|status
138
+ net offline|online|latency <ms>|drop|reset|status
139
+ record start|stop|note <text>|code [--component App --from ./App --props '{…}' --out file.test.tsx]
140
+ logs console errors/warnings and uncaught errors since the last call
141
+ overlay [on|off] the graph panel in the page (Alt+Shift+G)
142
+ screenshot [--out file.png] launched Chrome only; use your own browser's screenshot otherwise
143
+ close stop the launched Chrome`