@weft-js/cli 0.1.1 → 0.1.3
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 +534 -54
- package/bin/weft.mjs +8 -1
- package/docs/async.md +16 -6
- package/docs/browser.md +22 -8
- package/docs/config.md +39 -9
- package/docs/core.md +30 -0
- package/docs/deploy.md +33 -8
- package/docs/design.md +12 -3
- package/docs/entrypoints.md +79 -0
- package/docs/fonts.md +12 -2
- package/docs/icons.md +13 -7
- package/docs/lists.md +3 -1
- package/docs/meta.md +13 -3
- package/docs/plugins.md +32 -9
- package/docs/props.md +13 -2
- package/docs/reactivity.md +26 -0
- package/docs/rules.md +19 -12
- package/docs/rust.md +47 -17
- package/docs/start-short.md +3 -2
- package/docs/start.md +226 -52
- package/docs/state.md +33 -7
- package/docs/style.md +31 -8
- package/docs/surface.md +61 -15
- package/docs/testing.md +15 -5
- package/docs/theme.md +182 -0
- package/docs/ui-state.md +19 -6
- package/docs/verify.md +13 -0
- package/package.json +13 -4
- package/src/add/config.mjs +93 -0
- package/src/add/errors.mjs +2 -0
- package/src/add/setup.mjs +88 -0
- package/src/add-rust.mjs +82 -27
- package/src/add.mjs +209 -181
- package/src/agents-md.mjs +144 -43
- package/src/alias.mjs +16 -5
- package/src/audit/page.mjs +313 -0
- package/src/audit/report.mjs +69 -0
- package/src/audit/utils.mjs +5 -0
- package/src/audit.mjs +159 -206
- package/src/browser/cli.mjs +24 -5
- package/src/browser/client.mjs +189 -25
- package/src/browser/commands.mjs +166 -51
- package/src/catalog/audit.mjs +129 -0
- package/src/catalog/auth.mjs +40 -0
- package/src/catalog/compiler.mjs +337 -0
- package/src/catalog/contracts.mjs +18 -0
- package/src/catalog/database.mjs +52 -0
- package/src/catalog/deploy.mjs +117 -0
- package/src/catalog/design.mjs +159 -0
- package/src/catalog/forms.mjs +25 -0
- package/src/catalog/hydration.mjs +29 -0
- package/src/catalog/icons.mjs +24 -0
- package/src/catalog/media.mjs +163 -0
- package/src/catalog/metadata.mjs +116 -0
- package/src/catalog/plugins.mjs +228 -0
- package/src/catalog/realtime.mjs +195 -0
- package/src/catalog/rust.mjs +75 -0
- package/src/catalog/server-functions.mjs +92 -0
- package/src/catalog/state.mjs +39 -0
- package/src/catalog/style-groups.mjs +54 -0
- package/src/catalog/tests.mjs +127 -0
- package/src/catalog/text.mjs +200 -0
- package/src/catalog/ui-state.mjs +42 -0
- package/src/catalog.mjs +67 -460
- package/src/check/runtime.mjs +280 -0
- package/src/check.mjs +198 -208
- package/src/cli/authoring.mjs +213 -0
- package/src/cli/project.mjs +302 -0
- package/src/cli.mjs +222 -250
- package/src/config.mjs +1 -1
- package/src/contributions.mjs +75 -25
- package/src/db-layer.mjs +13 -5
- package/src/deploy/cdn.mjs +94 -16
- package/src/deploy/files.mjs +100 -21
- package/src/deploy/fit.mjs +127 -23
- package/src/deploy/hosts.mjs +90 -20
- package/src/deploy/index/cli.mjs +20 -0
- package/src/deploy/index/environment.mjs +69 -0
- package/src/deploy/index/steps.mjs +87 -0
- package/src/deploy/index.mjs +228 -114
- package/src/deploy/needs.mjs +88 -31
- package/src/design-checks/accessible-names.mjs +62 -0
- package/src/design-checks/contrast.mjs +43 -0
- package/src/design-checks/csp.mjs +60 -0
- package/src/design-checks/files.mjs +59 -0
- package/src/design-checks/heading-links.mjs +61 -0
- package/src/design-checks/interaction.mjs +65 -0
- package/src/design-checks/jsx.mjs +67 -0
- package/src/design-checks/layout-values.mjs +68 -0
- package/src/design-checks/native-controls.mjs +48 -0
- package/src/design-checks/outline.mjs +39 -0
- package/src/design-checks/reporter.mjs +37 -0
- package/src/design-checks/rules.mjs +274 -0
- package/src/design-checks/values.mjs +262 -0
- package/src/design-checks.mjs +10 -400
- package/src/diag.mjs +32 -8
- package/src/docs.mjs +138 -43
- package/src/doctor.mjs +20 -8
- package/src/files.mjs +18 -5
- package/src/fix.mjs +10 -2
- package/src/gate/after.mjs +40 -0
- package/src/gate/before.mjs +54 -0
- package/src/gate/classify.mjs +72 -0
- package/src/gate/mutation.mjs +209 -0
- package/src/gate/resolve-cases.mjs +22 -0
- package/src/gate/result.mjs +63 -0
- package/src/gate/run.mjs +75 -0
- package/src/gate.mjs +104 -202
- package/src/infer/box-props.cjs +140 -0
- package/src/infer/core-boxes.cjs +46 -0
- package/src/infer/derived-writes.cjs +95 -0
- package/src/infer/engine/checks.cjs +193 -0
- package/src/infer/engine/flow.cjs +293 -0
- package/src/infer/engine/report.cjs +158 -0
- package/src/infer/engine/utils.cjs +15 -0
- package/src/infer/engine.cjs +108 -368
- package/src/infer/facts/sites.cjs +114 -0
- package/src/infer/facts.cjs +210 -117
- package/src/infer/hosts.cjs +69 -22
- package/src/infer/lsp.mjs +149 -40
- package/src/infer/session.cjs +73 -17
- package/src/infer/shadow.cjs +13 -5
- package/src/infer/worker.cjs +57 -9
- package/src/jsx-scan.mjs +63 -21
- package/src/load-config/schema.mjs +281 -0
- package/src/load-config.mjs +137 -138
- package/src/map.mjs +93 -26
- package/src/mcp/browser-tools.mjs +155 -0
- package/src/mcp.mjs +147 -69
- package/src/meta-table.mjs +43 -8
- package/src/mutate.mjs +120 -36
- package/src/plugin-checks.mjs +52 -23
- package/src/plugins.mjs +125 -39
- package/src/project.mjs +170 -79
- package/src/props-checks.mjs +24 -6
- package/src/props-listener.mjs +27 -9
- package/src/react-checks.mjs +35 -4
- package/src/server-state-checks.mjs +34 -0
- package/src/share-checks.mjs +121 -37
- package/src/site.mjs +33 -6
- package/src/slots.mjs +104 -19
- package/src/start/build.mjs +116 -0
- package/src/start/routes.mjs +38 -0
- package/src/start.mjs +193 -95
- package/src/state-checks/core-state.mjs +223 -0
- package/src/state-checks/modules.mjs +17 -0
- package/src/state-checks/scan.mjs +232 -0
- package/src/state-checks/server-graph.mjs +65 -0
- package/src/state-checks.mjs +109 -133
- package/src/style/find.mjs +121 -0
- package/src/style.mjs +133 -127
- package/src/surface-checks.mjs +212 -46
- package/src/tailwind.mjs +5 -4
- package/src/tasks.mjs +189 -27
- package/src/test-rules/context.mjs +109 -0
- package/src/test-rules/file-rules.mjs +279 -0
- package/src/test-rules/rule-helpers.mjs +244 -0
- package/src/test-rules/syntax.mjs +223 -0
- package/src/test-rules/test-rules.mjs +127 -0
- package/src/testdiff.mjs +94 -32
- package/src/tests.mjs +31 -483
- package/src/text-checks.mjs +229 -69
- package/src/tokens.mjs +12 -4
- package/src/trust/scan.mjs +95 -0
- package/src/trust.mjs +214 -102
- package/src/weft.mjs +41 -9
- package/src/workspace.mjs +96 -19
- package/ts-plugin/context.js +57 -0
- package/ts-plugin/index.js +4 -124
- package/ts-plugin/mapping.js +65 -0
- package/ts-plugin/proxy.js +144 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { diagnostic } from '../diag.mjs'
|
|
2
|
+
|
|
3
|
+
/** What a rule means in a warp app, where warp is how it usually happens. */
|
|
4
|
+
const WARP = {
|
|
5
|
+
'heading-order':
|
|
6
|
+
"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. ",
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const say = {
|
|
10
|
+
L001: (f) =>
|
|
11
|
+
`${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.`,
|
|
12
|
+
L002: (f) =>
|
|
13
|
+
`${f.tag} (\`${f.selector}\`) is an image with no text alternative (${f.rule}): a screen reader reads its file name.`,
|
|
14
|
+
L003: (f) =>
|
|
15
|
+
`${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'}.`,
|
|
16
|
+
L004: (f) => `${f.tag} (\`${f.selector}\`): ${f.help} (axe \`${f.rule}\`).`,
|
|
17
|
+
L031: (f) =>
|
|
18
|
+
`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.`,
|
|
19
|
+
L010: (f) => `Console error${f.at ? ` at ${f.at}` : ''}: ${f.text}`,
|
|
20
|
+
L011: (f) =>
|
|
21
|
+
f.at === '/favicon.ico'
|
|
22
|
+
? '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`.'
|
|
23
|
+
: `The browser logged an error${f.at ? ` for ${f.at}` : ''}: ${f.text}`,
|
|
24
|
+
L012: (f) =>
|
|
25
|
+
`A deprecated browser API is in use${f.at ? ` at ${f.at}` : ''}: ${f.text}. Lighthouse marks Best Practices down for it.`,
|
|
26
|
+
L013: (f) =>
|
|
27
|
+
`Chrome logged a ${f.text} issue for the page. Lighthouse marks Best Practices down for it.`,
|
|
28
|
+
L020: () => 'The page has no `<title>`: search results and tabs show its URL.',
|
|
29
|
+
L021: () => 'The page has no meta description: search results make one up from the page.',
|
|
30
|
+
L022: () =>
|
|
31
|
+
'`<html>` has no `lang`: screen readers guess the language, and translation offers go wrong.',
|
|
32
|
+
L023: () =>
|
|
33
|
+
'No `<meta name="viewport" content="width=device-width, initial-scale=1">`: a phone renders the page at 980px and shrinks it.',
|
|
34
|
+
L024: (f) =>
|
|
35
|
+
`Search engines are told not to index this page, by ${f.text}.${f.header ? ' The audited endpoint serves this header: check its host or proxy rules as well as the page head.' : ''}`,
|
|
36
|
+
L025: (f) => `The page answered ${f.text}.`,
|
|
37
|
+
L030: (f) =>
|
|
38
|
+
`The page scrolls sideways on a phone: it is ${f.text} wider than the screen.${f.wide?.length ? ` Past the edge: ${f.wide.join(', ')}` : ''}`,
|
|
39
|
+
}
|
|
40
|
+
/** A page may ask not to be indexed, and a host's preview URL does on its behalf: L024 says so without failing. */
|
|
41
|
+
const SEVERITY = { L021: 'warning', L024: 'warning', L030: 'warning', L031: 'warning' }
|
|
42
|
+
|
|
43
|
+
export function reportFindings(findings, routes, forms, indexing = 'auto') {
|
|
44
|
+
const diagnostics = [...findings.values()]
|
|
45
|
+
.map((f) => {
|
|
46
|
+
const pages = routes.filter((r) => f.routes.has(r))
|
|
47
|
+
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 ? ', …' : ''})`}`
|
|
48
|
+
const severity =
|
|
49
|
+
(f.code === 'L031' && f.cls > 0.1) || (f.code === 'L024' && indexing === 'public')
|
|
50
|
+
? 'error'
|
|
51
|
+
: (SEVERITY[f.code] ?? 'error')
|
|
52
|
+
// an axe rule outside L001–L003 carries its own fix: the rule's first remedy, and its page
|
|
53
|
+
const help =
|
|
54
|
+
f.code === 'L004'
|
|
55
|
+
? `${f.fix ? `${f.fix}. ` : ''}${WARP[f.rule] ?? ''}The rule: https://dequeuniversity.com/rules/axe/4.14/${f.rule}`
|
|
56
|
+
: undefined
|
|
57
|
+
return diagnostic({
|
|
58
|
+
code: f.code,
|
|
59
|
+
severity,
|
|
60
|
+
file: pages[0],
|
|
61
|
+
why: `${say[f.code](f)} (${on})`,
|
|
62
|
+
help,
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
.sort((a, b) =>
|
|
66
|
+
a.severity === b.severity ? a.code.localeCompare(b.code) : a.severity === 'error' ? -1 : 1,
|
|
67
|
+
)
|
|
68
|
+
return diagnostics
|
|
69
|
+
}
|
package/src/audit.mjs
CHANGED
|
@@ -11,9 +11,12 @@ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync }
|
|
|
11
11
|
import { createServer } from 'node:http'
|
|
12
12
|
import { tmpdir } from 'node:os'
|
|
13
13
|
import { extname, join, relative, resolve, sep } from 'node:path'
|
|
14
|
-
import { fileURLToPath } from 'node:url'
|
|
15
14
|
import { findChrome } from './browser/client.mjs'
|
|
16
15
|
import { diagnostic } from './diag.mjs'
|
|
16
|
+
import { fail, sleep } from './audit/utils.mjs'
|
|
17
|
+
import { auditPage } from './audit/page.mjs'
|
|
18
|
+
import { reportFindings } from './audit/report.mjs'
|
|
19
|
+
import { loadConfig } from './load-config.mjs'
|
|
17
20
|
|
|
18
21
|
/** The two widths Lighthouse's defaults stand for: its mobile run (a Moto G Power) and its desktop run. */
|
|
19
22
|
export const FORMS = {
|
|
@@ -21,46 +24,44 @@ export const FORMS = {
|
|
|
21
24
|
desktop: { width: 1350, height: 940, deviceScaleFactor: 1, mobile: false },
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
'
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
'
|
|
27
|
+
const TYPES = {
|
|
28
|
+
'.html': 'text/html; charset=utf-8',
|
|
29
|
+
'.js': 'text/javascript',
|
|
30
|
+
'.mjs': 'text/javascript',
|
|
31
|
+
'.css': 'text/css',
|
|
32
|
+
'.json': 'application/json',
|
|
33
|
+
'.svg': 'image/svg+xml',
|
|
34
|
+
'.png': 'image/png',
|
|
35
|
+
'.jpg': 'image/jpeg',
|
|
36
|
+
'.jpeg': 'image/jpeg',
|
|
37
|
+
'.webp': 'image/webp',
|
|
38
|
+
'.avif': 'image/avif',
|
|
39
|
+
'.gif': 'image/gif',
|
|
40
|
+
'.ico': 'image/x-icon',
|
|
41
|
+
'.woff2': 'font/woff2',
|
|
42
|
+
'.woff': 'font/woff',
|
|
43
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
44
|
+
'.xml': 'application/xml',
|
|
45
|
+
'.wasm': 'application/wasm',
|
|
46
|
+
'.webmanifest': 'application/manifest+json',
|
|
43
47
|
}
|
|
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
48
|
|
|
54
49
|
/** The public directory a build wrote: the newer of `.output/public` (server presets) and `dist` (Netlify, Vercel static). */
|
|
55
50
|
export function builtPublic(root) {
|
|
56
|
-
const found = ['.output/public', 'dist']
|
|
57
|
-
|
|
51
|
+
const found = ['.output/public', 'dist']
|
|
52
|
+
.map((d) => join(root, d))
|
|
53
|
+
.filter((d) => existsSync(join(d, 'index.html')))
|
|
54
|
+
return (
|
|
55
|
+
found.sort(
|
|
56
|
+
(a, b) => statSync(join(b, 'index.html')).mtimeMs - statSync(join(a, 'index.html')).mtimeMs,
|
|
57
|
+
)[0] ?? null
|
|
58
|
+
)
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/** Every prerendered page under `dir`, as its URL path. */
|
|
61
62
|
export function pagesIn(dir) {
|
|
62
63
|
const out = []
|
|
63
|
-
const walk = d => {
|
|
64
|
+
const walk = (d) => {
|
|
64
65
|
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
65
66
|
if (e.name.startsWith('.') || e.name.startsWith('_')) continue
|
|
66
67
|
const f = join(d, e.name)
|
|
@@ -72,7 +73,7 @@ export function pagesIn(dir) {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
walk(dir)
|
|
75
|
-
return out.sort()
|
|
76
|
+
return out.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
/**
|
|
@@ -94,36 +95,69 @@ export function serveStatic(dir) {
|
|
|
94
95
|
dir = resolve(dir)
|
|
95
96
|
const server = createServer((req, res) => {
|
|
96
97
|
const path = decodeURIComponent(new URL(req.url, 'http://x').pathname)
|
|
97
|
-
const tries = [path, `${path}/index.html`, `${path.replace(/\/$/, '')}.html`].map(p =>
|
|
98
|
-
|
|
98
|
+
const tries = [path, `${path}/index.html`, `${path.replace(/\/$/, '')}.html`].map((p) =>
|
|
99
|
+
resolve(dir, `.${p}`),
|
|
100
|
+
)
|
|
101
|
+
const file = tries.find(
|
|
102
|
+
(f) => (f === dir || f.startsWith(dir + sep)) && existsSync(f) && statSync(f).isFile(),
|
|
103
|
+
)
|
|
99
104
|
const missing = join(dir, '404.html')
|
|
100
105
|
if (!file) {
|
|
101
106
|
res.writeHead(404, { 'content-type': TYPES['.html'] })
|
|
102
107
|
// a doctype even here: a frame showing this would put Chrome in Quirks Mode, an issue of the audit's own making
|
|
103
|
-
return res.end(
|
|
108
|
+
return res.end(
|
|
109
|
+
existsSync(missing)
|
|
110
|
+
? readFileSync(missing)
|
|
111
|
+
: '<!doctype html><title>Not found</title><p>Not found</p>',
|
|
112
|
+
)
|
|
104
113
|
}
|
|
105
114
|
res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' })
|
|
106
115
|
res.end(readFileSync(file))
|
|
107
116
|
})
|
|
108
|
-
return new Promise(ok
|
|
117
|
+
return new Promise((ok) => {
|
|
118
|
+
server.listen(0, '127.0.0.1', () =>
|
|
119
|
+
ok({ url: `http://127.0.0.1:${server.address().port}`, close: () => server.close() }),
|
|
120
|
+
)
|
|
121
|
+
})
|
|
109
122
|
}
|
|
110
123
|
|
|
111
124
|
/** 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. */
|
|
112
125
|
async function chrome(exe) {
|
|
113
126
|
const profile = mkdtempSync(join(tmpdir(), 'weft-audit-'))
|
|
114
|
-
const child = spawn(
|
|
127
|
+
const child = spawn(
|
|
128
|
+
exe,
|
|
129
|
+
[
|
|
130
|
+
`--user-data-dir=${profile}`,
|
|
131
|
+
'--headless=new',
|
|
132
|
+
'--remote-debugging-port=0',
|
|
133
|
+
'--no-first-run',
|
|
134
|
+
'--no-default-browser-check',
|
|
135
|
+
'--disable-extensions',
|
|
136
|
+
'--disable-background-networking',
|
|
137
|
+
'--mute-audio',
|
|
138
|
+
...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
|
|
139
|
+
'about:blank',
|
|
140
|
+
],
|
|
141
|
+
{ stdio: 'ignore' },
|
|
142
|
+
)
|
|
115
143
|
const portFile = join(profile, 'DevToolsActivePort')
|
|
116
144
|
for (let i = 0; !existsSync(portFile) || !readFileSync(portFile, 'utf8').includes('\n'); i++) {
|
|
117
|
-
if (i > 150 || child.exitCode !== null) {
|
|
145
|
+
if (i > 150 || child.exitCode !== null) {
|
|
146
|
+
child.kill()
|
|
147
|
+
throw fail('E_NO_CHROME', `Chrome at ${exe} didn't start`)
|
|
148
|
+
}
|
|
118
149
|
await sleep(100)
|
|
119
150
|
}
|
|
120
151
|
const [port, path] = readFileSync(portFile, 'utf8').split('\n')
|
|
121
152
|
const ws = new WebSocket(`ws://127.0.0.1:${port}${path}`)
|
|
122
|
-
await new Promise((ok, no) => {
|
|
153
|
+
await new Promise((ok, no) => {
|
|
154
|
+
ws.onopen = ok
|
|
155
|
+
ws.onerror = () => no(fail('E_CDP', 'could not reach Chrome over the DevTools protocol'))
|
|
156
|
+
})
|
|
123
157
|
let next = 0
|
|
124
158
|
const waiting = new Map()
|
|
125
159
|
const listeners = new Set()
|
|
126
|
-
ws.onmessage = e => {
|
|
160
|
+
ws.onmessage = (e) => {
|
|
127
161
|
const m = JSON.parse(e.data)
|
|
128
162
|
if (m.id !== undefined) {
|
|
129
163
|
const w = waiting.get(m.id)
|
|
@@ -132,38 +166,30 @@ async function chrome(exe) {
|
|
|
132
166
|
else w?.ok(m.result)
|
|
133
167
|
} else for (const on of listeners) on(m)
|
|
134
168
|
}
|
|
135
|
-
const send = (method, params = {}, sessionId) =>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
169
|
+
const send = (method, params = {}, sessionId) =>
|
|
170
|
+
new Promise((ok, no) => {
|
|
171
|
+
const id = ++next
|
|
172
|
+
waiting.set(id, { ok, no })
|
|
173
|
+
ws.send(JSON.stringify({ id, method, params, sessionId }))
|
|
174
|
+
})
|
|
140
175
|
const close = () => {
|
|
141
|
-
try {
|
|
176
|
+
try {
|
|
177
|
+
ws.close()
|
|
178
|
+
} catch {}
|
|
142
179
|
child.kill()
|
|
143
180
|
// Chrome holds the profile a moment after it's told to go.
|
|
144
181
|
setTimeout(() => rmSync(profile, { recursive: true, force: true }), 500).unref()
|
|
145
182
|
}
|
|
146
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
send,
|
|
185
|
+
on: (fn) => {
|
|
186
|
+
listeners.add(fn)
|
|
187
|
+
return () => listeners.delete(fn)
|
|
188
|
+
},
|
|
189
|
+
close,
|
|
190
|
+
}
|
|
147
191
|
}
|
|
148
192
|
|
|
149
|
-
/** In the page: how far the layout moved without a person asking (CLS, summed), and the first elements that moved. */
|
|
150
|
-
const SHIFT = `new Promise(ok => {
|
|
151
|
-
let cls = 0
|
|
152
|
-
const moved = []
|
|
153
|
-
new PerformanceObserver(list => {
|
|
154
|
-
for (const e of list.getEntries()) {
|
|
155
|
-
if (e.hadRecentInput) continue
|
|
156
|
-
cls += e.value
|
|
157
|
-
for (const s of e.sources ?? []) {
|
|
158
|
-
if (!s.node || s.node.nodeType !== 1 || moved.length === 3) continue
|
|
159
|
-
const open = s.node.outerHTML.slice(0, s.node.outerHTML.indexOf('>') + 1)
|
|
160
|
-
moved.push(open.length > 120 ? open.slice(0, 117) + '…>' : open)
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}).observe({ type: 'layout-shift', buffered: true })
|
|
164
|
-
setTimeout(() => ok({ cls: Math.round(cls * 1000) / 1000, moved }), 50)
|
|
165
|
-
})`
|
|
166
|
-
|
|
167
193
|
/**
|
|
168
194
|
* The app's own pages as a test on a path (`src/routes`, `[id]` and `[...rest]` included). A build served as files has
|
|
169
195
|
* only its prerendered pages: a request for one of the others is the server's to answer, not a missing file.
|
|
@@ -175,150 +201,66 @@ export function appRoutes(root) {
|
|
|
175
201
|
const walk = (at, prefix) => {
|
|
176
202
|
for (const e of readdirSync(at, { withFileTypes: true })) {
|
|
177
203
|
const name = e.name.replace(/^\(.*\)$/, '')
|
|
178
|
-
if (e.isDirectory()) {
|
|
179
|
-
|
|
204
|
+
if (e.isDirectory()) {
|
|
205
|
+
walk(join(at, e.name), name ? `${prefix}/${name}` : prefix)
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if (
|
|
209
|
+
!/\.[jt]sx?$/.test(e.name) ||
|
|
210
|
+
/\.(server|test|spec)\./.test(e.name) ||
|
|
211
|
+
e.name.startsWith('_')
|
|
212
|
+
)
|
|
213
|
+
continue
|
|
180
214
|
const leaf = e.name.replace(/\.[jt]sx?$/, '')
|
|
181
215
|
const path = leaf === 'index' ? prefix : `${prefix}/${leaf}`
|
|
182
|
-
const re = path
|
|
216
|
+
const re = path
|
|
217
|
+
.split('/')
|
|
218
|
+
.map((seg) =>
|
|
219
|
+
/^\[\.\.\.\w+\]$/.test(seg)
|
|
220
|
+
? '\0'
|
|
221
|
+
: /^\[\w+\]$/.test(seg)
|
|
222
|
+
? '[^/]+'
|
|
223
|
+
: seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
|
|
224
|
+
)
|
|
225
|
+
.join('/')
|
|
226
|
+
.replace(/\/\0/g, '(?:/.+)?')
|
|
183
227
|
tests.push(new RegExp(`^${re || ''}/?$`))
|
|
184
228
|
}
|
|
185
229
|
}
|
|
186
230
|
walk(dir, '')
|
|
187
|
-
return path => tests.some(t => t.test(path))
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** In the page: what a crawler reads from the head, and what pokes past a phone's right edge. */
|
|
191
|
-
const HEAD = `(() => {
|
|
192
|
-
const meta = n => document.querySelector('meta[name="' + n + '"]')?.getAttribute('content')?.trim() ?? null
|
|
193
|
-
const wide = []
|
|
194
|
-
const edge = innerWidth + 1
|
|
195
|
-
if (document.documentElement.scrollWidth > edge) {
|
|
196
|
-
for (const el of document.body.querySelectorAll('*')) {
|
|
197
|
-
const r = el.getBoundingClientRect()
|
|
198
|
-
if (r.right > edge && r.width > 0 && getComputedStyle(el).position !== 'fixed' && !(el.parentElement && el.parentElement.getBoundingClientRect().right > edge)) {
|
|
199
|
-
const open = el.outerHTML.slice(0, el.outerHTML.indexOf('>') + 1)
|
|
200
|
-
wide.push(open.length > 120 ? open.slice(0, 117) + '…>' : open)
|
|
201
|
-
if (wide.length === 3) break
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return {
|
|
206
|
-
title: document.title.trim(), lang: document.documentElement.getAttribute('lang') ?? '', description: meta('description'),
|
|
207
|
-
viewport: meta('viewport'), robots: meta('robots'), scroll: document.documentElement.scrollWidth - innerWidth, wide,
|
|
208
|
-
icon: !!document.querySelector('link[rel~="icon"]'),
|
|
209
|
-
}
|
|
210
|
-
})()`
|
|
211
|
-
|
|
212
|
-
/** Audit one page in one form factor: what Chrome said while it loaded, its head, its layout, and axe-core's rules. */
|
|
213
|
-
async function auditPage(browser, url, form, statics, served) {
|
|
214
|
-
const { targetId } = await browser.send('Target.createTarget', { url: 'about:blank' })
|
|
215
|
-
const { sessionId } = await browser.send('Target.attachToTarget', { targetId, flatten: true })
|
|
216
|
-
const send = (m, p) => browser.send(m, p, sessionId)
|
|
217
|
-
const found = []
|
|
218
|
-
let document = null
|
|
219
|
-
let loaded
|
|
220
|
-
const load = new Promise(ok => { loaded = ok })
|
|
221
|
-
const where = (u, line) => (u ? `${u.replace(/^https?:\/\/127\.0\.0\.1:\d+/, '')}${line === undefined ? '' : `:${line + 1}`}` : '')
|
|
222
|
-
const off = browser.on(m => {
|
|
223
|
-
if (m.sessionId !== sessionId) return
|
|
224
|
-
const p = m.params
|
|
225
|
-
if (m.method === 'Page.loadEventFired') loaded()
|
|
226
|
-
else if (m.method === 'Network.responseReceived' && p.type === 'Document' && !document) document = p.response
|
|
227
|
-
else if (m.method === 'Runtime.exceptionThrown') {
|
|
228
|
-
const d = p.exceptionDetails
|
|
229
|
-
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 })
|
|
230
|
-
} else if (m.method === 'Runtime.consoleAPICalled' && (p.type === 'error' || p.type === 'assert')) {
|
|
231
|
-
const text = p.args.map(a => a.value ?? a.description ?? '').join(' ').split('\n')[0]
|
|
232
|
-
const top = p.stackTrace?.callFrames?.[0]
|
|
233
|
-
found.push({ code: 'L010', key: text, at: where(top?.url, top?.lineNumber), text })
|
|
234
|
-
} else if (m.method === 'Log.entryAdded' && p.entry.level === 'error') {
|
|
235
|
-
// a build's pages served as files have no server: its realtime and function routes answer 404 here and nowhere else
|
|
236
|
-
if (statics && /\/_weft\/(rt|ws|fn|data|m|x)\b/.test(`${p.entry.url ?? ''} ${p.entry.text}`)) return
|
|
237
|
-
if (statics && p.entry.url && /status of 404/.test(p.entry.text) && served(new URL(p.entry.url).pathname)) return
|
|
238
|
-
// the favicon: Chrome asks for it lazily, once a session, so the head probe asks instead (below)
|
|
239
|
-
if (/\/favicon\.ico$/.test(p.entry.url ?? '')) return
|
|
240
|
-
found.push({ code: 'L011', key: `${p.entry.url ?? ''} ${p.entry.text}`, at: where(p.entry.url), text: p.entry.text })
|
|
241
|
-
} else if (m.method === 'Audits.issueAdded') {
|
|
242
|
-
const { code, details } = p.issue
|
|
243
|
-
if (code === 'DeprecationIssue') {
|
|
244
|
-
const d = details.deprecationIssueDetails
|
|
245
|
-
found.push({ code: 'L012', key: d.type, at: where(d.sourceCodeLocation?.url, d.sourceCodeLocation?.lineNumber), text: d.type })
|
|
246
|
-
} else if (ISSUES.has(code)) found.push({ code: 'L013', key: code, text: code.replace(/Issue$/, '').replace(/([a-z])([A-Z])/g, '$1 $2') })
|
|
247
|
-
}
|
|
248
|
-
})
|
|
249
|
-
try {
|
|
250
|
-
const f = FORMS[form]
|
|
251
|
-
await Promise.all(['Page.enable', 'Runtime.enable', 'Log.enable', 'Network.enable', 'Audits.enable'].map(m => send(m)))
|
|
252
|
-
await send('Emulation.setDeviceMetricsOverride', { width: f.width, height: f.height, deviceScaleFactor: f.deviceScaleFactor, mobile: f.mobile })
|
|
253
|
-
await send('Emulation.setTouchEmulationEnabled', { enabled: f.mobile, maxTouchPoints: f.mobile ? 5 : 1 })
|
|
254
|
-
await send('Page.navigate', { url })
|
|
255
|
-
await Promise.race([load, sleep(15_000)])
|
|
256
|
-
await sleep(800) // hydration's errors, and a font swap's shift, land after load
|
|
257
|
-
if (document && document.status >= 400) found.push({ code: 'L025', key: String(document.status), text: `HTTP ${document.status}` })
|
|
258
|
-
const header = Object.entries(document?.headers ?? {}).find(([k]) => k.toLowerCase() === 'x-robots-tag')?.[1]
|
|
259
|
-
const head = (await send('Runtime.evaluate', { expression: HEAD, returnByValue: true })).result.value
|
|
260
|
-
if (!head.icon) {
|
|
261
|
-
const status = (await send('Runtime.evaluate', { expression: `fetch('/favicon.ico', { method: 'HEAD' }).then(r => r.status, () => 0)`, awaitPromise: true, returnByValue: true })).result.value
|
|
262
|
-
if (status >= 400) found.push({ code: 'L011', key: 'favicon', at: '/favicon.ico', text: `HTTP ${status}` })
|
|
263
|
-
}
|
|
264
|
-
if (!head.title) found.push({ code: 'L020', key: 'title' })
|
|
265
|
-
if (!head.description) found.push({ code: 'L021', key: 'description' })
|
|
266
|
-
if (!head.lang) found.push({ code: 'L022', key: 'lang' })
|
|
267
|
-
if (!head.viewport || !/width=device-width/.test(head.viewport)) found.push({ code: 'L023', key: 'viewport' })
|
|
268
|
-
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)
|
|
269
|
-
if (noindex.length) found.push({ code: 'L024', key: noindex.join(), text: noindex.join(' and '), header: !!header })
|
|
270
|
-
if (f.mobile && head.scroll > 0) found.push({ code: 'L030', key: head.wide.join() || 'page', text: `${head.scroll}px`, wide: head.wide })
|
|
271
|
-
const shift = (await send('Runtime.evaluate', { expression: SHIFT, awaitPromise: true, returnByValue: true })).result.value
|
|
272
|
-
if (shift.cls > 0.05) found.push({ code: 'L031', key: shift.moved.join() || 'page', cls: shift.cls, moved: shift.moved })
|
|
273
|
-
await send('Runtime.evaluate', { expression: axeSource() })
|
|
274
|
-
const { result, exceptionDetails } = await send('Runtime.evaluate', { expression: `axe.run(document, ${JSON.stringify(AXE)}).then(r => r.violations)`, awaitPromise: true, returnByValue: true })
|
|
275
|
-
if (exceptionDetails) throw fail('E_AUDIT', `axe-core failed on ${url}: ${exceptionDetails.exception?.description ?? exceptionDetails.text}`)
|
|
276
|
-
for (const v of result.value) {
|
|
277
|
-
for (const n of v.nodes) {
|
|
278
|
-
const tag = n.html.slice(0, n.html.indexOf('>') + 1)
|
|
279
|
-
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 })
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
return found
|
|
283
|
-
} finally {
|
|
284
|
-
off()
|
|
285
|
-
await browser.send('Target.closeTarget', { targetId }).catch(() => {})
|
|
286
|
-
}
|
|
231
|
+
return (path) => tests.some((t) => t.test(path))
|
|
287
232
|
}
|
|
288
233
|
|
|
289
|
-
const say = {
|
|
290
|
-
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.`,
|
|
291
|
-
L002: f => `${f.tag} (\`${f.selector}\`) is an image with no text alternative (${f.rule}): a screen reader reads its file name.`,
|
|
292
|
-
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'}.`,
|
|
293
|
-
L004: f => `${f.tag} (\`${f.selector}\`): ${f.help} (axe \`${f.rule}\`).`,
|
|
294
|
-
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.`,
|
|
295
|
-
L010: f => `Console error${f.at ? ` at ${f.at}` : ''}: ${f.text}`,
|
|
296
|
-
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}`),
|
|
297
|
-
L012: f => `A deprecated browser API is in use${f.at ? ` at ${f.at}` : ''}: ${f.text}. Lighthouse marks Best Practices down for it.`,
|
|
298
|
-
L013: f => `Chrome logged a ${f.text} issue for the page. Lighthouse marks Best Practices down for it.`,
|
|
299
|
-
L020: () => 'The page has no `<title>`: search results and tabs show its URL.',
|
|
300
|
-
L021: () => 'The page has no meta description: search results make one up from the page.',
|
|
301
|
-
L022: () => '`<html>` has no `lang`: screen readers guess the language, and translation offers go wrong.',
|
|
302
|
-
L023: () => 'No `<meta name="viewport" content="width=device-width, initial-scale=1">`: a phone renders the page at 980px and shrinks it.',
|
|
303
|
-
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.' : ''}`,
|
|
304
|
-
L025: f => `The page answered ${f.text}.`,
|
|
305
|
-
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(', ')}` : ''}`,
|
|
306
|
-
}
|
|
307
|
-
/** A page may ask not to be indexed, and a host's preview URL does on its behalf: L024 says so without failing. */
|
|
308
|
-
const SEVERITY = { L021: 'warning', L024: 'warning', L030: 'warning', L031: 'warning' }
|
|
309
|
-
|
|
310
234
|
/**
|
|
311
235
|
* @param {string} root project root
|
|
312
|
-
* @param {{ url?: string, dir?: string, routes?: string[], all?: boolean, max?: number, forms?: (keyof FORMS)[], concurrency?: number }} [options]
|
|
236
|
+
* @param {{ url?: string, dir?: string, routes?: string[], all?: boolean, max?: number, forms?: (keyof FORMS)[], concurrency?: number, indexing?: 'auto' | 'public' | 'private' }} [options]
|
|
313
237
|
* `url`: audit a running site (a deploy, `weft start`); else `dir`, a build's public directory (found when left out)
|
|
314
238
|
* @returns {Promise<{ ok: boolean, skipped?: string, base: string, pages: string[], diagnostics: object[], ms: number }>}
|
|
315
239
|
*/
|
|
316
240
|
export async function audit(root, options = {}) {
|
|
317
241
|
const t = performance.now()
|
|
242
|
+
const loaded = options.indexing === undefined ? await loadConfig(root) : undefined
|
|
243
|
+
if (loaded?.diagnostics?.some((d) => d.severity === 'error'))
|
|
244
|
+
throw new Error('Fix weft.config before auditing, or pass --indexing explicitly')
|
|
245
|
+
const configured = loaded?.config?.build?.audit
|
|
246
|
+
const indexing =
|
|
247
|
+
options.indexing ?? (typeof configured === 'object' ? configured.indexing : undefined) ?? 'auto'
|
|
248
|
+
if (!['auto', 'public', 'private'].includes(indexing))
|
|
249
|
+
throw new Error('indexing must be auto, public or private')
|
|
318
250
|
const exe = findChrome()
|
|
319
251
|
const forms = options.forms ?? ['phone', 'desktop']
|
|
320
|
-
const skip = why => ({
|
|
321
|
-
|
|
252
|
+
const skip = (why) => ({
|
|
253
|
+
ok: true,
|
|
254
|
+
skipped: why,
|
|
255
|
+
base: options.url ?? '',
|
|
256
|
+
pages: [],
|
|
257
|
+
diagnostics: [diagnostic({ code: 'L000', severity: 'warning', file: 'weft.config.ts', why })],
|
|
258
|
+
ms: 0,
|
|
259
|
+
})
|
|
260
|
+
if (!exe)
|
|
261
|
+
return skip(
|
|
262
|
+
'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.',
|
|
263
|
+
)
|
|
322
264
|
let base = options.url?.replace(/\/$/, '')
|
|
323
265
|
let server
|
|
324
266
|
let routes = options.routes
|
|
@@ -326,7 +268,10 @@ export async function audit(root, options = {}) {
|
|
|
326
268
|
const dir = options.dir ?? builtPublic(root)
|
|
327
269
|
if (!dir) return skip('No built pages to audit: run `weft build` first, or pass `--url`.')
|
|
328
270
|
const pages = pagesIn(dir)
|
|
329
|
-
if (!pages.length)
|
|
271
|
+
if (!pages.length)
|
|
272
|
+
return skip(
|
|
273
|
+
`No prerendered pages in ${relative(root, dir)} to audit: pass \`--url\` to audit a running server.`,
|
|
274
|
+
)
|
|
330
275
|
routes ??= options.all ? pages : samplePages(pages, options.max)
|
|
331
276
|
server = await serveStatic(dir)
|
|
332
277
|
base = server.url
|
|
@@ -336,11 +281,18 @@ export async function audit(root, options = {}) {
|
|
|
336
281
|
const browser = await chrome(exe)
|
|
337
282
|
const findings = new Map()
|
|
338
283
|
try {
|
|
339
|
-
const jobs = routes.flatMap(route => forms.map(form => ({ route, form })))
|
|
284
|
+
const jobs = routes.flatMap((route) => forms.map((form) => ({ route, form })))
|
|
340
285
|
let i = 0
|
|
341
286
|
const worker = async () => {
|
|
342
287
|
for (let job; (job = jobs[i++]);) {
|
|
343
|
-
for (const f of await auditPage(
|
|
288
|
+
for (const f of await auditPage({
|
|
289
|
+
browser,
|
|
290
|
+
url: `${base}${job.route}`,
|
|
291
|
+
form: job.form,
|
|
292
|
+
statics: !!server,
|
|
293
|
+
served,
|
|
294
|
+
forms: FORMS,
|
|
295
|
+
})) {
|
|
344
296
|
const key = `${f.code} ${f.key}`
|
|
345
297
|
const seen = findings.get(key) ?? { ...f, routes: new Set(), forms: new Set() }
|
|
346
298
|
seen.routes.add(job.route)
|
|
@@ -349,18 +301,19 @@ export async function audit(root, options = {}) {
|
|
|
349
301
|
}
|
|
350
302
|
}
|
|
351
303
|
}
|
|
352
|
-
await Promise.all(
|
|
304
|
+
await Promise.all(
|
|
305
|
+
Array.from({ length: Math.min(options.concurrency ?? 1, jobs.length) }, worker),
|
|
306
|
+
)
|
|
353
307
|
} finally {
|
|
354
308
|
browser.close()
|
|
355
309
|
server?.close()
|
|
356
310
|
}
|
|
357
|
-
const diagnostics =
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
365
|
-
return { ok: !diagnostics.some(d => d.severity === 'error'), base: options.url ?? base, pages: routes, diagnostics, ms: Math.round(performance.now() - t) }
|
|
311
|
+
const diagnostics = reportFindings(findings, routes, forms, indexing)
|
|
312
|
+
return {
|
|
313
|
+
ok: !diagnostics.some((d) => d.severity === 'error'),
|
|
314
|
+
base: options.url ?? base,
|
|
315
|
+
pages: routes,
|
|
316
|
+
diagnostics,
|
|
317
|
+
ms: Math.round(performance.now() - t),
|
|
318
|
+
}
|
|
366
319
|
}
|
package/src/browser/cli.mjs
CHANGED
|
@@ -11,23 +11,42 @@ export async function browserCommand(args, { print, say, io }) {
|
|
|
11
11
|
return cmd ? 0 : 2
|
|
12
12
|
}
|
|
13
13
|
try {
|
|
14
|
-
const result = await browser(args.root, cmd, cliParams(cmd, words, args.flags), {
|
|
14
|
+
const result = await browser(args.root, cmd, cliParams(cmd, words, args.flags), {
|
|
15
|
+
url: args.flags.url,
|
|
16
|
+
tab: args.flags.tab,
|
|
17
|
+
})
|
|
15
18
|
if (args.json) print(result)
|
|
16
19
|
else io.stdout.write(`${human(cmd, result)}\n`)
|
|
17
20
|
return result?.ok === false ? 1 : 0
|
|
18
21
|
} catch (error) {
|
|
19
22
|
if (error.code === 'E_USAGE') throw error
|
|
20
|
-
if (args.json)
|
|
23
|
+
if (args.json)
|
|
24
|
+
print({
|
|
25
|
+
ok: false,
|
|
26
|
+
error: { code: error.code ?? 'E_INTERNAL', message: error.message, tabs: error.tabs },
|
|
27
|
+
})
|
|
21
28
|
say(`weft browser ${cmd}: ${error.message}`)
|
|
22
29
|
return 1
|
|
23
30
|
}
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
function human(cmd, r) {
|
|
27
|
-
const text =
|
|
28
|
-
|
|
34
|
+
const text = (v) =>
|
|
35
|
+
typeof v === 'string' ? v : v?.path ? `(${v.chars} chars in ${v.path})\n${v.head}` : ''
|
|
36
|
+
if (cmd === 'snapshot')
|
|
37
|
+
return [
|
|
38
|
+
`${r.title} ${r.url}`,
|
|
39
|
+
r.delta ?? text(r.tree),
|
|
40
|
+
r.graph ? `\n# graph\n${text(r.graph)}` : '',
|
|
41
|
+
r.errors?.length
|
|
42
|
+
? `\n# errors\n${r.errors.map((e) => `${e.message}${e.at ? ` (at ${e.at})` : ''}`).join('\n')}`
|
|
43
|
+
: '',
|
|
44
|
+
]
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join('\n')
|
|
29
47
|
if (cmd === 'act') return `${r.target}: ${r.changes}`
|
|
30
|
-
if (cmd === 'assert')
|
|
48
|
+
if (cmd === 'assert')
|
|
49
|
+
return `${r.ok ? 'ok' : 'FAIL'} ${r.target} ${JSON.stringify(r.expected)}${r.ok ? '' : `, got ${JSON.stringify(r.actual)}`}`
|
|
31
50
|
if (cmd === 'graph') return text(r.graph)
|
|
32
51
|
if (cmd === 'record' && r.code) return r.code
|
|
33
52
|
return JSON.stringify(r, null, 2)
|