@weft-js/cli 0.1.0 → 0.1.2
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 +38 -14
- 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 +108 -0
- 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 +174 -188
- 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 +68 -444
- package/src/check/runtime.mjs +280 -0
- package/src/check.mjs +199 -207
- package/src/cli/authoring.mjs +213 -0
- package/src/cli/project.mjs +302 -0
- package/src/cli.mjs +228 -224
- package/src/config.mjs +1 -1
- package/src/contributions.mjs +75 -25
- package/src/db-layer.mjs +13 -5
- package/src/deploy/cdn.mjs +142 -0
- package/src/deploy/files.mjs +266 -0
- package/src/deploy/fit.mjs +166 -0
- package/src/deploy/hosts.mjs +126 -0
- 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 +387 -0
- package/src/deploy/needs.mjs +149 -0
- 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 -42
- 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 +144 -116
- 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 +194 -92
- 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,35 +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
|
|
108
|
+
return res.end(
|
|
109
|
+
existsSync(missing)
|
|
110
|
+
? readFileSync(missing)
|
|
111
|
+
: '<!doctype html><title>Not found</title><p>Not found</p>',
|
|
112
|
+
)
|
|
103
113
|
}
|
|
104
114
|
res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' })
|
|
105
115
|
res.end(readFileSync(file))
|
|
106
116
|
})
|
|
107
|
-
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
|
+
})
|
|
108
122
|
}
|
|
109
123
|
|
|
110
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. */
|
|
111
125
|
async function chrome(exe) {
|
|
112
126
|
const profile = mkdtempSync(join(tmpdir(), 'weft-audit-'))
|
|
113
|
-
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
|
+
)
|
|
114
143
|
const portFile = join(profile, 'DevToolsActivePort')
|
|
115
144
|
for (let i = 0; !existsSync(portFile) || !readFileSync(portFile, 'utf8').includes('\n'); i++) {
|
|
116
|
-
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
|
+
}
|
|
117
149
|
await sleep(100)
|
|
118
150
|
}
|
|
119
151
|
const [port, path] = readFileSync(portFile, 'utf8').split('\n')
|
|
120
152
|
const ws = new WebSocket(`ws://127.0.0.1:${port}${path}`)
|
|
121
|
-
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
|
+
})
|
|
122
157
|
let next = 0
|
|
123
158
|
const waiting = new Map()
|
|
124
159
|
const listeners = new Set()
|
|
125
|
-
ws.onmessage = e => {
|
|
160
|
+
ws.onmessage = (e) => {
|
|
126
161
|
const m = JSON.parse(e.data)
|
|
127
162
|
if (m.id !== undefined) {
|
|
128
163
|
const w = waiting.get(m.id)
|
|
@@ -131,162 +166,101 @@ async function chrome(exe) {
|
|
|
131
166
|
else w?.ok(m.result)
|
|
132
167
|
} else for (const on of listeners) on(m)
|
|
133
168
|
}
|
|
134
|
-
const send = (method, params = {}, sessionId) =>
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
})
|
|
139
175
|
const close = () => {
|
|
140
|
-
try {
|
|
176
|
+
try {
|
|
177
|
+
ws.close()
|
|
178
|
+
} catch {}
|
|
141
179
|
child.kill()
|
|
142
180
|
// Chrome holds the profile a moment after it's told to go.
|
|
143
181
|
setTimeout(() => rmSync(profile, { recursive: true, force: true }), 500).unref()
|
|
144
182
|
}
|
|
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
183
|
return {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
+
send,
|
|
185
|
+
on: (fn) => {
|
|
186
|
+
listeners.add(fn)
|
|
187
|
+
return () => listeners.delete(fn)
|
|
188
|
+
},
|
|
189
|
+
close,
|
|
184
190
|
}
|
|
185
|
-
}
|
|
191
|
+
}
|
|
186
192
|
|
|
187
|
-
/**
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 })
|
|
193
|
+
/**
|
|
194
|
+
* The app's own pages as a test on a path (`src/routes`, `[id]` and `[...rest]` included). A build served as files has
|
|
195
|
+
* only its prerendered pages: a request for one of the others is the server's to answer, not a missing file.
|
|
196
|
+
*/
|
|
197
|
+
export function appRoutes(root) {
|
|
198
|
+
const dir = join(root, 'src', 'routes')
|
|
199
|
+
if (!existsSync(dir)) return () => false
|
|
200
|
+
const tests = []
|
|
201
|
+
const walk = (at, prefix) => {
|
|
202
|
+
for (const e of readdirSync(at, { withFileTypes: true })) {
|
|
203
|
+
const name = e.name.replace(/^\(.*\)$/, '')
|
|
204
|
+
if (e.isDirectory()) {
|
|
205
|
+
walk(join(at, e.name), name ? `${prefix}/${name}` : prefix)
|
|
206
|
+
continue
|
|
248
207
|
}
|
|
208
|
+
if (
|
|
209
|
+
!/\.[jt]sx?$/.test(e.name) ||
|
|
210
|
+
/\.(server|test|spec)\./.test(e.name) ||
|
|
211
|
+
e.name.startsWith('_')
|
|
212
|
+
)
|
|
213
|
+
continue
|
|
214
|
+
const leaf = e.name.replace(/\.[jt]sx?$/, '')
|
|
215
|
+
const path = leaf === 'index' ? prefix : `${prefix}/${leaf}`
|
|
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, '(?:/.+)?')
|
|
227
|
+
tests.push(new RegExp(`^${re || ''}/?$`))
|
|
249
228
|
}
|
|
250
|
-
return found
|
|
251
|
-
} finally {
|
|
252
|
-
off()
|
|
253
|
-
await browser.send('Target.closeTarget', { targetId }).catch(() => {})
|
|
254
229
|
}
|
|
230
|
+
walk(dir, '')
|
|
231
|
+
return (path) => tests.some((t) => t.test(path))
|
|
255
232
|
}
|
|
256
233
|
|
|
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
234
|
/**
|
|
279
235
|
* @param {string} root project root
|
|
280
|
-
* @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]
|
|
281
237
|
* `url`: audit a running site (a deploy, `weft start`); else `dir`, a build's public directory (found when left out)
|
|
282
238
|
* @returns {Promise<{ ok: boolean, skipped?: string, base: string, pages: string[], diagnostics: object[], ms: number }>}
|
|
283
239
|
*/
|
|
284
240
|
export async function audit(root, options = {}) {
|
|
285
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')
|
|
286
250
|
const exe = findChrome()
|
|
287
251
|
const forms = options.forms ?? ['phone', 'desktop']
|
|
288
|
-
const skip = why => ({
|
|
289
|
-
|
|
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
|
+
)
|
|
290
264
|
let base = options.url?.replace(/\/$/, '')
|
|
291
265
|
let server
|
|
292
266
|
let routes = options.routes
|
|
@@ -294,20 +268,31 @@ export async function audit(root, options = {}) {
|
|
|
294
268
|
const dir = options.dir ?? builtPublic(root)
|
|
295
269
|
if (!dir) return skip('No built pages to audit: run `weft build` first, or pass `--url`.')
|
|
296
270
|
const pages = pagesIn(dir)
|
|
297
|
-
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
|
+
)
|
|
298
275
|
routes ??= options.all ? pages : samplePages(pages, options.max)
|
|
299
276
|
server = await serveStatic(dir)
|
|
300
277
|
base = server.url
|
|
301
278
|
}
|
|
302
279
|
routes ??= ['/']
|
|
280
|
+
const served = appRoutes(root)
|
|
303
281
|
const browser = await chrome(exe)
|
|
304
282
|
const findings = new Map()
|
|
305
283
|
try {
|
|
306
|
-
const jobs = routes.flatMap(route => forms.map(form => ({ route, form })))
|
|
284
|
+
const jobs = routes.flatMap((route) => forms.map((form) => ({ route, form })))
|
|
307
285
|
let i = 0
|
|
308
286
|
const worker = async () => {
|
|
309
287
|
for (let job; (job = jobs[i++]);) {
|
|
310
|
-
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
|
+
})) {
|
|
311
296
|
const key = `${f.code} ${f.key}`
|
|
312
297
|
const seen = findings.get(key) ?? { ...f, routes: new Set(), forms: new Set() }
|
|
313
298
|
seen.routes.add(job.route)
|
|
@@ -316,18 +301,19 @@ export async function audit(root, options = {}) {
|
|
|
316
301
|
}
|
|
317
302
|
}
|
|
318
303
|
}
|
|
319
|
-
await Promise.all(
|
|
304
|
+
await Promise.all(
|
|
305
|
+
Array.from({ length: Math.min(options.concurrency ?? 1, jobs.length) }, worker),
|
|
306
|
+
)
|
|
320
307
|
} finally {
|
|
321
308
|
browser.close()
|
|
322
309
|
server?.close()
|
|
323
310
|
}
|
|
324
|
-
const diagnostics =
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
332
|
-
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
|
+
}
|
|
333
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)
|