@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,223 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import ts from 'typescript'
|
|
3
|
+
import { local } from '../alias.mjs'
|
|
4
|
+
|
|
5
|
+
const suiteRoots = new Set(['describe', 'suite', 'fdescribe', 'xdescribe'])
|
|
6
|
+
const testRoots = new Set(['test', 'it', 'fit', 'xit', 'xtest', ...suiteRoots])
|
|
7
|
+
const exts = [
|
|
8
|
+
'',
|
|
9
|
+
'.ts',
|
|
10
|
+
'.tsx',
|
|
11
|
+
'.mts',
|
|
12
|
+
'.js',
|
|
13
|
+
'.jsx',
|
|
14
|
+
'.mjs',
|
|
15
|
+
'/index.ts',
|
|
16
|
+
'/index.tsx',
|
|
17
|
+
'/index.js',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const kindOf = (file) =>
|
|
21
|
+
file.endsWith('.tsx')
|
|
22
|
+
? ts.ScriptKind.TSX
|
|
23
|
+
: file.endsWith('.jsx')
|
|
24
|
+
? ts.ScriptKind.JSX
|
|
25
|
+
: /\.[mc]?ts$/.test(file)
|
|
26
|
+
? ts.ScriptKind.TS
|
|
27
|
+
: ts.ScriptKind.JS
|
|
28
|
+
export const parse = (text, file) =>
|
|
29
|
+
ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, kindOf(file))
|
|
30
|
+
export const isFn = (n) =>
|
|
31
|
+
n && (ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n))
|
|
32
|
+
export const norm = (n) => n.getText().replace(/\s+/g, ' ').replace(/;$/, '')
|
|
33
|
+
export const unwrap = (n) => {
|
|
34
|
+
while (
|
|
35
|
+
n &&
|
|
36
|
+
(ts.isParenthesizedExpression(n) ||
|
|
37
|
+
ts.isAsExpression(n) ||
|
|
38
|
+
ts.isNonNullExpression(n) ||
|
|
39
|
+
ts.isAwaitExpression(n))
|
|
40
|
+
)
|
|
41
|
+
n = n.expression
|
|
42
|
+
return n
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function walk(node, visit) {
|
|
46
|
+
const go = (n) => {
|
|
47
|
+
if (visit(n) === false) return
|
|
48
|
+
ts.forEachChild(n, go)
|
|
49
|
+
}
|
|
50
|
+
go(node)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const contains = (node, pred) => {
|
|
54
|
+
let hit = false
|
|
55
|
+
walk(node, (n) => {
|
|
56
|
+
if (hit) return false
|
|
57
|
+
if (pred(n)) hit = true
|
|
58
|
+
})
|
|
59
|
+
return hit
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The identifier at the root of `a.b(…).c`, or undefined. */
|
|
63
|
+
export function rootName(expr) {
|
|
64
|
+
let n = expr
|
|
65
|
+
for (;;) {
|
|
66
|
+
n = unwrap(n)
|
|
67
|
+
if (
|
|
68
|
+
ts.isPropertyAccessExpression(n) ||
|
|
69
|
+
ts.isElementAccessExpression(n) ||
|
|
70
|
+
ts.isCallExpression(n)
|
|
71
|
+
)
|
|
72
|
+
n = n.expression
|
|
73
|
+
else break
|
|
74
|
+
}
|
|
75
|
+
return ts.isIdentifier(n) ? n.text : undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `1`, `'a'`, `null`, `[1, { a: 'b' }]`: a value with nothing behind it. */
|
|
79
|
+
export function isLiteral(n) {
|
|
80
|
+
n = unwrap(n)
|
|
81
|
+
if (!n) return false
|
|
82
|
+
if (
|
|
83
|
+
ts.isStringLiteral(n) ||
|
|
84
|
+
ts.isNumericLiteral(n) ||
|
|
85
|
+
ts.isBigIntLiteral(n) ||
|
|
86
|
+
ts.isNoSubstitutionTemplateLiteral(n) ||
|
|
87
|
+
ts.isRegularExpressionLiteral(n)
|
|
88
|
+
)
|
|
89
|
+
return true
|
|
90
|
+
if (
|
|
91
|
+
[ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword, ts.SyntaxKind.NullKeyword].includes(
|
|
92
|
+
n.kind,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return true
|
|
96
|
+
if (ts.isIdentifier(n)) return n.text === 'undefined' || n.text === 'NaN' || n.text === 'Infinity'
|
|
97
|
+
if (ts.isPrefixUnaryExpression(n)) return isLiteral(n.operand)
|
|
98
|
+
if (ts.isArrayLiteralExpression(n)) return n.elements.every(isLiteral)
|
|
99
|
+
if (ts.isObjectLiteralExpression(n))
|
|
100
|
+
return n.properties.every((p) => ts.isPropertyAssignment(p) && isLiteral(p.initializer))
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const hasCall = (n) =>
|
|
105
|
+
contains(
|
|
106
|
+
n,
|
|
107
|
+
(x) =>
|
|
108
|
+
ts.isCallExpression(x) ||
|
|
109
|
+
ts.isNewExpression(x) ||
|
|
110
|
+
ts.isAwaitExpression(x) ||
|
|
111
|
+
ts.isTaggedTemplateExpression(x),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
/** `expect(x)`, `expect.soft(x)`, `t.expect(x)`: the call that takes the subject. */
|
|
115
|
+
export function isSubject(call) {
|
|
116
|
+
const c = call.expression
|
|
117
|
+
if (ts.isIdentifier(c)) return c.text === 'expect'
|
|
118
|
+
if (!ts.isPropertyAccessExpression(c)) return false
|
|
119
|
+
if (c.name.text === 'expect') return true
|
|
120
|
+
return (
|
|
121
|
+
ts.isIdentifier(c.expression) &&
|
|
122
|
+
c.expression.text === 'expect' &&
|
|
123
|
+
['soft', 'poll'].includes(c.name.text)
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** For a subject call, the matcher it ends in: `expect(x).not.toBe(1)` → { name: 'toBe', call, not: true }. */
|
|
128
|
+
export function matcherOf(subject) {
|
|
129
|
+
let cur = subject
|
|
130
|
+
let not = false
|
|
131
|
+
const mods = []
|
|
132
|
+
for (;;) {
|
|
133
|
+
const p = cur.parent
|
|
134
|
+
if (p && ts.isPropertyAccessExpression(p) && p.expression === cur) {
|
|
135
|
+
const name = p.name.text
|
|
136
|
+
if (p.parent && ts.isCallExpression(p.parent) && p.parent.expression === p)
|
|
137
|
+
return { name, node: p.name, call: p.parent, not, mods }
|
|
138
|
+
if (name === 'not') not = !not
|
|
139
|
+
mods.push(name)
|
|
140
|
+
cur = p
|
|
141
|
+
} else return null
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Test and suite calls, with their modifiers: `test.only.each(table)('x', fn)`. */
|
|
146
|
+
export function testCall(call) {
|
|
147
|
+
// The outer call of a curried form (`test.each(t)(…)`) is the test; skip the inner one.
|
|
148
|
+
if (call.parent && ts.isCallExpression(call.parent) && call.parent.expression === call)
|
|
149
|
+
return null
|
|
150
|
+
const mods = []
|
|
151
|
+
let n = call.expression
|
|
152
|
+
for (;;) {
|
|
153
|
+
if (ts.isPropertyAccessExpression(n)) {
|
|
154
|
+
mods.unshift({ name: n.name.text, node: n })
|
|
155
|
+
n = n.expression
|
|
156
|
+
} else if (ts.isCallExpression(n)) n = n.expression
|
|
157
|
+
else break
|
|
158
|
+
}
|
|
159
|
+
if (!ts.isIdentifier(n) || !testRoots.has(n.text)) return null
|
|
160
|
+
const root = n.text
|
|
161
|
+
const fn = [...call.arguments]
|
|
162
|
+
.reverse()
|
|
163
|
+
.find((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a))
|
|
164
|
+
// `fit` with no test body is someone's function (@weft-js/core/text's), not a focused test
|
|
165
|
+
if (root === 'fit' && !fn) return null
|
|
166
|
+
return { root, rootNode: n, mods, fn, suite: suiteRoots.has(root), call }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Functions (by name) whose bodies assert, in one file: local helpers and, given `imported`, what they call. */
|
|
170
|
+
export function assertingFunctions(sf, imported = new Set()) {
|
|
171
|
+
const bodies = new Map()
|
|
172
|
+
const add = (name, fn) => fn?.body && bodies.set(name, fn.body)
|
|
173
|
+
for (const st of sf.statements) {
|
|
174
|
+
const decl = ts.isExportDeclaration(st) ? null : st
|
|
175
|
+
if (decl && ts.isFunctionDeclaration(decl) && decl.name) add(decl.name.text, decl)
|
|
176
|
+
if (decl && ts.isVariableStatement(decl))
|
|
177
|
+
for (const d of decl.declarationList.declarations)
|
|
178
|
+
if (ts.isIdentifier(d.name) && isFn(d.initializer)) add(d.name.text, d.initializer)
|
|
179
|
+
}
|
|
180
|
+
const asserting = new Set(imported)
|
|
181
|
+
for (let changed = true; changed;) {
|
|
182
|
+
changed = false
|
|
183
|
+
for (const [name, body] of bodies) {
|
|
184
|
+
if (asserting.has(name) || !asserts(body, asserting)) continue
|
|
185
|
+
asserting.add(name)
|
|
186
|
+
changed = true
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return asserting
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Does this code assert? A matcher, `assert…`, `expect.assertions`, a helper named `expect…`, or a known asserting function. */
|
|
193
|
+
export function asserts(node, asserting) {
|
|
194
|
+
return contains(node, (n) => {
|
|
195
|
+
if (!ts.isCallExpression(n)) return false
|
|
196
|
+
if (isSubject(n)) return Boolean(matcherOf(n))
|
|
197
|
+
const c = n.expression
|
|
198
|
+
if (
|
|
199
|
+
ts.isPropertyAccessExpression(c) &&
|
|
200
|
+
ts.isIdentifier(c.expression) &&
|
|
201
|
+
c.expression.text === 'expect'
|
|
202
|
+
)
|
|
203
|
+
return true // expect.assertions(n)
|
|
204
|
+
const name = ts.isIdentifier(c)
|
|
205
|
+
? c.text
|
|
206
|
+
: ts.isPropertyAccessExpression(c) && ts.isIdentifier(c.expression)
|
|
207
|
+
? c.expression.text
|
|
208
|
+
: undefined
|
|
209
|
+
return Boolean(name && (/^(expect|assert)([A-Z_]|$)/.test(name) || asserting.has(name)))
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function resolveImport(fromAbs, spec) {
|
|
214
|
+
const base = local(fromAbs, spec)
|
|
215
|
+
if (!base) return null
|
|
216
|
+
for (const e of exts) {
|
|
217
|
+
const p = base + e
|
|
218
|
+
if (existsSync(p) && !p.endsWith('/') && (e || /\.[cm]?[jt]sx?$/.test(p))) return p
|
|
219
|
+
}
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const moduleKey = (abs) => abs.replace(/(\/index)?\.[cm]?[jt]sx?$/, '')
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
import { asserts, isSubject, matcherOf, norm, walk } from './syntax.mjs'
|
|
3
|
+
import { assertsWhatItSet, conditional } from './rule-helpers.mjs'
|
|
4
|
+
|
|
5
|
+
const focusRoots = { fit: 'it', fdescribe: 'describe' }
|
|
6
|
+
const skipRoots = { xit: 'it', xtest: 'test', xdescribe: 'describe' }
|
|
7
|
+
|
|
8
|
+
export function focusRule(t, { sf, edit, report }) {
|
|
9
|
+
const focus =
|
|
10
|
+
t.mods.find((m) => m.name === 'only') ??
|
|
11
|
+
(focusRoots[t.root] && { name: 'only', node: t.rootNode })
|
|
12
|
+
const skip =
|
|
13
|
+
t.mods.find((m) => m.name === 'skip') ??
|
|
14
|
+
(skipRoots[t.root] && { name: 'skip', node: t.rootNode })
|
|
15
|
+
for (const m of [focus, skip].filter(Boolean)) {
|
|
16
|
+
const kind = t.suite ? 'suite' : 'test'
|
|
17
|
+
let fix
|
|
18
|
+
if (m.node === t.rootNode) {
|
|
19
|
+
const to = focusRoots[t.root] ?? skipRoots[t.root]
|
|
20
|
+
fix = {
|
|
21
|
+
description: `Rename \`${t.root}\` to \`${to}\``,
|
|
22
|
+
safe: m.name === 'only',
|
|
23
|
+
edits: [edit(m.node.getStart(sf), m.node.getEnd(), to)],
|
|
24
|
+
}
|
|
25
|
+
report(
|
|
26
|
+
'T009',
|
|
27
|
+
m.node,
|
|
28
|
+
`\`${t.root}\` ${m.name === 'only' ? 'focuses this ' + kind + ': every other test in the run is silently skipped' : 'skips this ' + kind + ': it never runs, so it proves nothing'}.`,
|
|
29
|
+
{ fix },
|
|
30
|
+
)
|
|
31
|
+
} else {
|
|
32
|
+
const dot = m.node.expression.getEnd()
|
|
33
|
+
fix = {
|
|
34
|
+
description: `Remove \`.${m.name}\``,
|
|
35
|
+
safe: m.name === 'only',
|
|
36
|
+
edits: [edit(dot, m.node.getEnd(), '')],
|
|
37
|
+
}
|
|
38
|
+
report(
|
|
39
|
+
'T009',
|
|
40
|
+
m.node.name,
|
|
41
|
+
m.name === 'only'
|
|
42
|
+
? `\`.only\` focuses this ${kind}: every other test in the run is silently skipped.`
|
|
43
|
+
: `\`.skip\` turns this ${kind} off: it never runs, so it proves nothing. Fix or delete it; a test that is meant to fail is \`test.fails\`.`,
|
|
44
|
+
{ fix },
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function assertionRule(t, { asserting, report }, n, previouslyNoAssertion) {
|
|
51
|
+
if (n) {
|
|
52
|
+
if (!previouslyNoAssertion)
|
|
53
|
+
report('T001', n, `\`${norm(n)}\` has no matcher, so it asserts nothing.`, {
|
|
54
|
+
help: 'Finish it: `expect(x).toBe(…)`, `.toHaveTextContent(…)`, …',
|
|
55
|
+
})
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
const body = t.fn.body
|
|
59
|
+
const noAssertion = !asserts(body, asserting)
|
|
60
|
+
if (noAssertion) {
|
|
61
|
+
const title =
|
|
62
|
+
t.call.arguments[0] && ts.isStringLiteralLike(t.call.arguments[0])
|
|
63
|
+
? ` "${t.call.arguments[0].text}"`
|
|
64
|
+
: ''
|
|
65
|
+
report(
|
|
66
|
+
'T001',
|
|
67
|
+
t.call.expression,
|
|
68
|
+
`Test${title} makes no assertion: it passes as long as nothing throws.`,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return noAssertion
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function conditionalRule(t, ctx, noAssertion) {
|
|
76
|
+
const { report } = ctx
|
|
77
|
+
const body = t.fn.body
|
|
78
|
+
// A looped `expect` is fine next to one that always runs; a conditional one never is.
|
|
79
|
+
const looped = []
|
|
80
|
+
let always = false
|
|
81
|
+
walk(body, (n) => {
|
|
82
|
+
if (!ts.isCallExpression(n) || !isSubject(n)) return
|
|
83
|
+
const m = matcherOf(n)
|
|
84
|
+
if (!m) {
|
|
85
|
+
assertionRule(t, ctx, n, noAssertion)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
let why
|
|
89
|
+
for (let a = n.parent; a && a !== t.fn && !why; a = a.parent) why = conditional(a, n)
|
|
90
|
+
if (!why) always = true
|
|
91
|
+
else if (why.loop) looped.push([m.call, why.text])
|
|
92
|
+
else
|
|
93
|
+
report(
|
|
94
|
+
'T008',
|
|
95
|
+
m.call,
|
|
96
|
+
`This \`expect\` only runs ${why.text}, so the test can pass without checking anything.`,
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
if (!always)
|
|
100
|
+
for (const [call, how] of looped)
|
|
101
|
+
report(
|
|
102
|
+
'T008',
|
|
103
|
+
call,
|
|
104
|
+
`Every \`expect\` in this test runs ${how}, so the test can pass without checking anything.`,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function setRule(t, { report }) {
|
|
109
|
+
const body = t.fn.body
|
|
110
|
+
if (ts.isBlock(body)) assertsWhatItSet(body.statements, report)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const testRules = [focusRule, assertionRule, conditionalRule, setRule]
|
|
114
|
+
|
|
115
|
+
export function runTestRules(t, ctx) {
|
|
116
|
+
testRules[0](t, ctx)
|
|
117
|
+
if (
|
|
118
|
+
t.suite ||
|
|
119
|
+
!t.fn ||
|
|
120
|
+
t.mods.some((m) => ['todo', 'skip'].includes(m.name)) ||
|
|
121
|
+
skipRoots[t.root]
|
|
122
|
+
)
|
|
123
|
+
return
|
|
124
|
+
const noAssertion = testRules[1](t, ctx)
|
|
125
|
+
testRules[2](t, ctx, noAssertion)
|
|
126
|
+
testRules[3](t, ctx)
|
|
127
|
+
}
|
package/src/testdiff.mjs
CHANGED
|
@@ -11,11 +11,12 @@ const skipMods = new Set(['skip', 'todo'])
|
|
|
11
11
|
const skipRoots = new Set(['xit', 'xtest', 'xdescribe'])
|
|
12
12
|
|
|
13
13
|
/** A case's name as vitest reports it: suite titles and the test title joined by ' > '. */
|
|
14
|
-
const join = names => names.join(' > ')
|
|
14
|
+
const join = (names) => names.join(' > ')
|
|
15
15
|
|
|
16
16
|
function nameOf(arg) {
|
|
17
17
|
if (!arg) return { name: '<anonymous>', dynamic: true }
|
|
18
|
-
if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg))
|
|
18
|
+
if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg))
|
|
19
|
+
return { name: arg.text, dynamic: false }
|
|
19
20
|
return { name: arg.getText(), dynamic: true }
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -31,51 +32,74 @@ export function testCases(text, rel) {
|
|
|
31
32
|
if (ts.isCallExpression(node)) {
|
|
32
33
|
const t = testCall(node)
|
|
33
34
|
if (t) {
|
|
34
|
-
const each = t.mods.some(m => m.name === 'each' || m.name === 'for')
|
|
35
|
+
const each = t.mods.some((m) => m.name === 'each' || m.name === 'for')
|
|
35
36
|
const { name, dynamic: dyn } = nameOf(node.arguments[0])
|
|
36
|
-
const skip = skipped || skipRoots.has(t.root) || t.mods.some(m => skipMods.has(m.name))
|
|
37
|
+
const skip = skipped || skipRoots.has(t.root) || t.mods.some((m) => skipMods.has(m.name))
|
|
37
38
|
const isDynamic = dynamic || dyn || each
|
|
38
39
|
if (t.suite) {
|
|
39
|
-
if (t.fn?.body)
|
|
40
|
+
if (t.fn?.body)
|
|
41
|
+
ts.forEachChild(t.fn.body, (c) => visit(c, [...suites, name], skip, isDynamic))
|
|
40
42
|
return
|
|
41
43
|
}
|
|
42
44
|
const asserts = []
|
|
43
45
|
let expected = null
|
|
44
46
|
if (t.fn?.body)
|
|
45
|
-
walk(t.fn.body, n => {
|
|
47
|
+
walk(t.fn.body, (n) => {
|
|
46
48
|
if (!ts.isCallExpression(n)) return
|
|
47
49
|
if (isSubject(n)) {
|
|
48
50
|
const m = matcherOf(n)
|
|
49
51
|
if (!m) return
|
|
50
52
|
const subject = n.arguments[0] ? norm(n.arguments[0]) : ''
|
|
51
53
|
const args = m.call.arguments.map(norm).join(', ')
|
|
52
|
-
asserts.push({
|
|
53
|
-
|
|
54
|
+
asserts.push({
|
|
55
|
+
key: `${subject}|${m.not ? 'not.' : ''}${m.name}|${args}`,
|
|
56
|
+
subject,
|
|
57
|
+
matcher: m.name,
|
|
58
|
+
not: m.not,
|
|
59
|
+
args,
|
|
60
|
+
line: pos.lineCol(n.getStart(sf))[0],
|
|
61
|
+
})
|
|
62
|
+
} else if (
|
|
63
|
+
ts.isPropertyAccessExpression(n.expression) &&
|
|
64
|
+
ts.isIdentifier(n.expression.expression) &&
|
|
65
|
+
n.expression.expression.text === 'expect' &&
|
|
66
|
+
n.expression.name.text === 'assertions'
|
|
67
|
+
) {
|
|
54
68
|
expected = Number(n.arguments[0]?.getText()) || null
|
|
55
69
|
}
|
|
56
70
|
})
|
|
57
71
|
out.push({
|
|
58
|
-
name: join([...suites, name]),
|
|
59
|
-
|
|
72
|
+
name: join([...suites, name]),
|
|
73
|
+
line: pos.lineCol(node.getStart(sf))[0],
|
|
74
|
+
end: pos.lineCol(node.getEnd())[0],
|
|
75
|
+
body: t.fn?.body ? norm(t.fn.body) : '',
|
|
76
|
+
skipped: skip,
|
|
77
|
+
fails: t.mods.some((m) => m.name === 'fails'),
|
|
78
|
+
dynamic: isDynamic,
|
|
79
|
+
asserts,
|
|
80
|
+
expected,
|
|
60
81
|
})
|
|
61
82
|
return
|
|
62
83
|
}
|
|
63
84
|
}
|
|
64
|
-
ts.forEachChild(node, c => visit(c, suites, skipped, dynamic))
|
|
85
|
+
ts.forEachChild(node, (c) => visit(c, suites, skipped, dynamic))
|
|
65
86
|
}
|
|
66
87
|
visit(sf, [], false, false)
|
|
67
88
|
return out
|
|
68
89
|
}
|
|
69
90
|
|
|
70
91
|
function walk(node, fn) {
|
|
71
|
-
const go = n => {
|
|
92
|
+
const go = (n) => {
|
|
93
|
+
fn(n)
|
|
94
|
+
ts.forEachChild(n, go)
|
|
95
|
+
}
|
|
72
96
|
go(node)
|
|
73
97
|
}
|
|
74
98
|
|
|
75
99
|
/** The test names in `after` that are new or whose body changed since `before` (null: the file is new). */
|
|
76
100
|
export function changedCases(before, after, rel) {
|
|
77
|
-
const old = new Map((before == null ? [] : testCases(before, rel)).map(c => [c.name, c]))
|
|
78
|
-
return testCases(after, rel).filter(c => !c.skipped && old.get(c.name)?.body !== c.body)
|
|
101
|
+
const old = new Map((before == null ? [] : testCases(before, rel)).map((c) => [c.name, c]))
|
|
102
|
+
return testCases(after, rel).filter((c) => !c.skipped && old.get(c.name)?.body !== c.body)
|
|
79
103
|
}
|
|
80
104
|
|
|
81
105
|
/**
|
|
@@ -101,7 +125,8 @@ const looser = {
|
|
|
101
125
|
|
|
102
126
|
function loosened(a, b) {
|
|
103
127
|
if (a.subject !== b.subject || a.not !== b.not) return false
|
|
104
|
-
if (a.matcher === 'toThrow' || a.matcher === 'toThrowError')
|
|
128
|
+
if (a.matcher === 'toThrow' || a.matcher === 'toThrowError')
|
|
129
|
+
return b.matcher.startsWith('toThrow') && a.args && !b.args
|
|
105
130
|
return looser[a.matcher]?.includes(b.matcher) ?? false
|
|
106
131
|
}
|
|
107
132
|
|
|
@@ -113,16 +138,30 @@ export function weakened(before, after, rel) {
|
|
|
113
138
|
if (before == null) return []
|
|
114
139
|
const old = testCases(before, rel)
|
|
115
140
|
if (after == null) {
|
|
116
|
-
const live = old.filter(c => !c.skipped)
|
|
117
|
-
return live.length
|
|
141
|
+
const live = old.filter((c) => !c.skipped)
|
|
142
|
+
return live.length
|
|
143
|
+
? [
|
|
144
|
+
t020(
|
|
145
|
+
rel,
|
|
146
|
+
null,
|
|
147
|
+
1,
|
|
148
|
+
`${rel} was deleted with ${live.length} test${live.length > 1 ? 's' : ''} in it (${live
|
|
149
|
+
.slice(0, 3)
|
|
150
|
+
.map((c) => `"${c.name}"`)
|
|
151
|
+
.join(', ')}${live.length > 3 ? ', …' : ''})`,
|
|
152
|
+
),
|
|
153
|
+
]
|
|
154
|
+
: []
|
|
118
155
|
}
|
|
119
156
|
const sf = parse(after, rel)
|
|
120
157
|
const allowed = allowances(after, sf)
|
|
121
158
|
const now = testCases(after, rel)
|
|
122
|
-
const byName = new Map(now.map(c => [c.name, c]))
|
|
123
|
-
const byBody = new Set(now.map(c => c.body))
|
|
159
|
+
const byName = new Map(now.map((c) => [c.name, c]))
|
|
160
|
+
const byBody = new Set(now.map((c) => c.body))
|
|
124
161
|
const out = []
|
|
125
|
-
const report = (line, why) => {
|
|
162
|
+
const report = (line, why) => {
|
|
163
|
+
if (!allowed('T020', line)) out.push(t020(rel, after, line, why))
|
|
164
|
+
}
|
|
126
165
|
for (const o of old) {
|
|
127
166
|
if (o.skipped || o.dynamic) continue
|
|
128
167
|
const n = byName.get(o.name)
|
|
@@ -130,20 +169,40 @@ export function weakened(before, after, rel) {
|
|
|
130
169
|
if (!byBody.has(o.body)) report(1, `test "${o.name}" was deleted`)
|
|
131
170
|
continue
|
|
132
171
|
}
|
|
133
|
-
if (n.skipped) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
172
|
+
if (n.skipped) {
|
|
173
|
+
report(n.line, `test "${o.name}" is now skipped`)
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
if (n.fails && !o.fails) {
|
|
177
|
+
report(n.line, `test "${o.name}" is now marked \`.fails\``)
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
if (o.expected && (n.expected ?? 0) < o.expected)
|
|
181
|
+
report(
|
|
182
|
+
n.line,
|
|
183
|
+
`test "${o.name}" now expects ${n.expected ?? 'no'} assertions instead of ${o.expected}`,
|
|
184
|
+
)
|
|
185
|
+
const nowKeys = new Set(n.asserts.map((a) => a.key))
|
|
186
|
+
const oldKeys = new Set(o.asserts.map((a) => a.key))
|
|
187
|
+
const gone = o.asserts.filter((a) => !nowKeys.has(a.key))
|
|
188
|
+
const added = n.asserts.filter((a) => !oldKeys.has(a.key))
|
|
140
189
|
for (const a of gone) {
|
|
141
|
-
const loose = added.find(b => loosened(a, b))
|
|
142
|
-
if (loose)
|
|
190
|
+
const loose = added.find((b) => loosened(a, b))
|
|
191
|
+
if (loose)
|
|
192
|
+
report(
|
|
193
|
+
loose.line,
|
|
194
|
+
`in "${o.name}", \`expect(${a.subject}).${a.not ? 'not.' : ''}${a.matcher}(${a.args})\` became \`${loose.not ? 'not.' : ''}${loose.matcher}(${loose.args})\`, which accepts more`,
|
|
195
|
+
)
|
|
143
196
|
}
|
|
144
|
-
const replaced = gone.filter(a => !added.some(b => loosened(a, b)))
|
|
197
|
+
const replaced = gone.filter((a) => !added.some((b) => loosened(a, b)))
|
|
145
198
|
if (n.asserts.length < o.asserts.length && replaced.length)
|
|
146
|
-
report(
|
|
199
|
+
report(
|
|
200
|
+
n.line,
|
|
201
|
+
`"${o.name}" lost ${o.asserts.length - n.asserts.length} assertion${o.asserts.length - n.asserts.length > 1 ? 's' : ''}: ${replaced
|
|
202
|
+
.slice(0, 2)
|
|
203
|
+
.map((a) => `\`expect(${a.subject}).${a.matcher}(…)\``)
|
|
204
|
+
.join(', ')}`,
|
|
205
|
+
)
|
|
147
206
|
}
|
|
148
207
|
return out
|
|
149
208
|
}
|
|
@@ -151,7 +210,10 @@ export function weakened(before, after, rel) {
|
|
|
151
210
|
function t020(rel, _text, line, why) {
|
|
152
211
|
const span = [line, 1, line, 1]
|
|
153
212
|
return diagnostic({
|
|
154
|
-
code: 'T020',
|
|
213
|
+
code: 'T020',
|
|
214
|
+
file: rel,
|
|
215
|
+
span,
|
|
216
|
+
why: `${why}. An existing test got weaker, so it may no longer catch what it used to.`,
|
|
155
217
|
help: 'Restore it; or, when the change really is intended (the old test pinned a bug), keep it with `// weft-allow T020: <reason>` on that test, where the reviewer sees it',
|
|
156
218
|
})
|
|
157
219
|
}
|