@weft-js/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +74 -2
- package/bin/weft.mjs +8 -0
- package/docs/async.md +40 -0
- package/docs/browser.md +39 -0
- package/docs/config.md +93 -0
- package/docs/design.md +97 -0
- package/docs/fonts.md +70 -0
- package/docs/icons.md +102 -0
- package/docs/lists.md +23 -0
- package/docs/meta.md +82 -0
- package/docs/plugins.md +143 -0
- package/docs/props.md +77 -0
- package/docs/rules.md +32 -0
- package/docs/rust.md +223 -0
- package/docs/start-short.md +8 -0
- package/docs/start.md +422 -0
- package/docs/state.md +96 -0
- package/docs/style.md +59 -0
- package/docs/surface.md +111 -0
- package/docs/testing.md +71 -0
- package/docs/ui-state.md +92 -0
- package/docs/verify.md +48 -0
- package/package.json +39 -4
- package/src/add-rust.mjs +123 -0
- package/src/add.mjs +358 -0
- package/src/agents-md.mjs +134 -0
- package/src/alias.mjs +59 -0
- package/src/audit.mjs +333 -0
- package/src/browser/cli.mjs +34 -0
- package/src/browser/client.mjs +132 -0
- package/src/browser/commands.mjs +143 -0
- package/src/catalog.mjs +479 -0
- package/src/check.mjs +328 -0
- package/src/cli.mjs +332 -0
- package/src/config.d.ts +1 -0
- package/src/config.mjs +5 -0
- package/src/contributions.mjs +69 -0
- package/src/db-layer.mjs +33 -0
- package/src/design-checks.mjs +439 -0
- package/src/diag.mjs +74 -0
- package/src/docs.mjs +131 -0
- package/src/doctor.mjs +30 -0
- package/src/files.mjs +47 -0
- package/src/fix.mjs +40 -0
- package/src/gate.mjs +276 -0
- package/src/index.mjs +9 -0
- package/src/infer/engine.cjs +525 -0
- package/src/infer/facts.cjs +252 -0
- package/src/infer/hosts.cjs +114 -0
- package/src/infer/lsp.mjs +131 -0
- package/src/infer/session.cjs +105 -0
- package/src/infer/shadow.cjs +46 -0
- package/src/infer/worker.cjs +97 -0
- package/src/jsx-scan.mjs +86 -0
- package/src/load-config.mjs +274 -0
- package/src/map.mjs +110 -0
- package/src/mcp.mjs +195 -0
- package/src/meta-table.mjs +28 -0
- package/src/mutate.mjs +140 -0
- package/src/plugin-checks.mjs +89 -0
- package/src/plugins.mjs +172 -0
- package/src/project.mjs +219 -0
- package/src/props-checks.mjs +46 -0
- package/src/props-listener.mjs +38 -0
- package/src/react-checks.mjs +36 -0
- package/src/share-checks.mjs +74 -0
- package/src/site.mjs +19 -0
- package/src/slots.mjs +96 -0
- package/src/start.mjs +292 -0
- package/src/state-checks.mjs +213 -0
- package/src/style.mjs +320 -0
- package/src/surface-checks.mjs +218 -0
- package/src/tailwind.mjs +22 -0
- package/src/tasks.mjs +99 -0
- package/src/testdiff.mjs +157 -0
- package/src/tests.mjs +572 -0
- package/src/text-checks.mjs +172 -0
- package/src/tokens.mjs +24 -0
- package/src/trust.mjs +282 -0
- package/src/weft.mjs +66 -0
- package/src/workspace.mjs +105 -0
- package/ts-plugin/index.js +137 -0
- package/ts-plugin/package.json +1 -0
package/src/tests.mjs
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
// Static T-codes for test files (PLAN §7b, research 05 §5.1): tests that can't be junk.
|
|
2
|
+
//
|
|
3
|
+
// T001 no assertion T004 sleeps / real timers T007 unapproved snapshot
|
|
4
|
+
// T002 tautology T005 implementation queries T008 conditional or looped `expect`
|
|
5
|
+
// T003 asserts what it set T006 mocks weft or the unit T009 `.only` / `.skip`
|
|
6
|
+
// T013 a real socket
|
|
7
|
+
//
|
|
8
|
+
// Syntax only (TypeScript's parser, no type checker), like the project view. `@weft-js/core/test` adds a runtime T001 for
|
|
9
|
+
// assertions this pass can't see. A finding a test needs on purpose is kept with a written reason:
|
|
10
|
+
//
|
|
11
|
+
// // weft-allow T004, T005: hydration tests read the server DOM and wait for real timers
|
|
12
|
+
//
|
|
13
|
+
// in the file's leading comments covers the whole file; anywhere else it covers its own line and the next one.
|
|
14
|
+
// Without a reason it is ignored.
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
17
|
+
import { dirname, join, relative } from 'node:path'
|
|
18
|
+
import ts from 'typescript'
|
|
19
|
+
import { local } from './alias.mjs'
|
|
20
|
+
import { diagnostic, positions } from './diag.mjs'
|
|
21
|
+
|
|
22
|
+
export const testGlobs = ['**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']
|
|
23
|
+
|
|
24
|
+
const suiteRoots = new Set(['describe', 'suite', 'fdescribe', 'xdescribe'])
|
|
25
|
+
const testRoots = new Set(['test', 'it', 'fit', 'xit', 'xtest', ...suiteRoots])
|
|
26
|
+
const focusRoots = { fit: 'it', fdescribe: 'describe' }
|
|
27
|
+
const skipRoots = { xit: 'it', xtest: 'test', xdescribe: 'describe' }
|
|
28
|
+
const snapshotMatchers = new Set(['toMatchSnapshot', 'toMatchInlineSnapshot', 'toMatchFileSnapshot', 'toThrowErrorMatchingSnapshot', 'toThrowErrorMatchingInlineSnapshot'])
|
|
29
|
+
const sameValueMatchers = new Set(['toBe', 'toEqual', 'toStrictEqual'])
|
|
30
|
+
const domQueries = new Set(['querySelector', 'querySelectorAll', 'getElementsByClassName', 'getElementsByTagName', 'getElementById', 'closest'])
|
|
31
|
+
const loopCallbacks = new Set(['forEach', 'map', 'flatMap', 'filter', 'some', 'every', 'find', 'findIndex', 'reduce'])
|
|
32
|
+
const timerCalls = new Set(['setTimeout', 'setInterval', 'requestAnimationFrame', 'setImmediate'])
|
|
33
|
+
const exts = ['', '.ts', '.tsx', '.mts', '.js', '.jsx', '.mjs', '/index.ts', '/index.tsx', '/index.js']
|
|
34
|
+
|
|
35
|
+
const kindOf = file => (file.endsWith('.tsx') ? ts.ScriptKind.TSX : file.endsWith('.jsx') ? ts.ScriptKind.JSX : /\.[mc]?ts$/.test(file) ? ts.ScriptKind.TS : ts.ScriptKind.JS)
|
|
36
|
+
export const parse = (text, file) => ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, kindOf(file))
|
|
37
|
+
const isFn = n => n && (ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n))
|
|
38
|
+
export const norm = n => n.getText().replace(/\s+/g, ' ').replace(/;$/, '')
|
|
39
|
+
const unwrap = n => {
|
|
40
|
+
while (n && (ts.isParenthesizedExpression(n) || ts.isAsExpression(n) || ts.isNonNullExpression(n) || ts.isAwaitExpression(n))) n = n.expression
|
|
41
|
+
return n
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function walk(node, visit) {
|
|
45
|
+
const go = n => {
|
|
46
|
+
if (visit(n) === false) return
|
|
47
|
+
ts.forEachChild(n, go)
|
|
48
|
+
}
|
|
49
|
+
go(node)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const contains = (node, pred) => {
|
|
53
|
+
let hit = false
|
|
54
|
+
walk(node, n => {
|
|
55
|
+
if (hit) return false
|
|
56
|
+
if (pred(n)) hit = true
|
|
57
|
+
})
|
|
58
|
+
return hit
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The identifier at the root of `a.b(…).c`, or undefined. */
|
|
62
|
+
function rootName(expr) {
|
|
63
|
+
let n = expr
|
|
64
|
+
for (;;) {
|
|
65
|
+
n = unwrap(n)
|
|
66
|
+
if (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n) || ts.isCallExpression(n)) n = n.expression
|
|
67
|
+
else break
|
|
68
|
+
}
|
|
69
|
+
return ts.isIdentifier(n) ? n.text : undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `1`, `'a'`, `null`, `[1, { a: 'b' }]`: a value with nothing behind it. */
|
|
73
|
+
function isLiteral(n) {
|
|
74
|
+
n = unwrap(n)
|
|
75
|
+
if (!n) return false
|
|
76
|
+
if (ts.isStringLiteral(n) || ts.isNumericLiteral(n) || ts.isBigIntLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n) || ts.isRegularExpressionLiteral(n)) return true
|
|
77
|
+
if ([ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword, ts.SyntaxKind.NullKeyword].includes(n.kind)) return true
|
|
78
|
+
if (ts.isIdentifier(n)) return n.text === 'undefined' || n.text === 'NaN' || n.text === 'Infinity'
|
|
79
|
+
if (ts.isPrefixUnaryExpression(n)) return isLiteral(n.operand)
|
|
80
|
+
if (ts.isArrayLiteralExpression(n)) return n.elements.every(isLiteral)
|
|
81
|
+
if (ts.isObjectLiteralExpression(n)) return n.properties.every(p => ts.isPropertyAssignment(p) && isLiteral(p.initializer))
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const hasCall = n => contains(n, x => ts.isCallExpression(x) || ts.isNewExpression(x) || ts.isAwaitExpression(x) || ts.isTaggedTemplateExpression(x))
|
|
86
|
+
|
|
87
|
+
/** `expect(x)`, `expect.soft(x)`, `t.expect(x)`: the call that takes the subject. */
|
|
88
|
+
export function isSubject(call) {
|
|
89
|
+
const c = call.expression
|
|
90
|
+
if (ts.isIdentifier(c)) return c.text === 'expect'
|
|
91
|
+
if (!ts.isPropertyAccessExpression(c)) return false
|
|
92
|
+
if (c.name.text === 'expect') return true
|
|
93
|
+
return ts.isIdentifier(c.expression) && c.expression.text === 'expect' && ['soft', 'poll'].includes(c.name.text)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** For a subject call, the matcher it ends in: `expect(x).not.toBe(1)` → { name: 'toBe', call, not: true }. */
|
|
97
|
+
export function matcherOf(subject) {
|
|
98
|
+
let cur = subject
|
|
99
|
+
let not = false
|
|
100
|
+
const mods = []
|
|
101
|
+
for (;;) {
|
|
102
|
+
const p = cur.parent
|
|
103
|
+
if (p && ts.isPropertyAccessExpression(p) && p.expression === cur) {
|
|
104
|
+
const name = p.name.text
|
|
105
|
+
if (p.parent && ts.isCallExpression(p.parent) && p.parent.expression === p) return { name, node: p.name, call: p.parent, not, mods }
|
|
106
|
+
if (name === 'not') not = !not
|
|
107
|
+
mods.push(name)
|
|
108
|
+
cur = p
|
|
109
|
+
} else return null
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Test and suite calls, with their modifiers: `test.only.each(table)('x', fn)`. */
|
|
114
|
+
export function testCall(call) {
|
|
115
|
+
// The outer call of a curried form (`test.each(t)(…)`) is the test; skip the inner one.
|
|
116
|
+
if (call.parent && ts.isCallExpression(call.parent) && call.parent.expression === call) return null
|
|
117
|
+
const mods = []
|
|
118
|
+
let n = call.expression
|
|
119
|
+
for (;;) {
|
|
120
|
+
if (ts.isPropertyAccessExpression(n)) {
|
|
121
|
+
mods.unshift({ name: n.name.text, node: n })
|
|
122
|
+
n = n.expression
|
|
123
|
+
} else if (ts.isCallExpression(n)) n = n.expression
|
|
124
|
+
else break
|
|
125
|
+
}
|
|
126
|
+
if (!ts.isIdentifier(n) || !testRoots.has(n.text)) return null
|
|
127
|
+
const root = n.text
|
|
128
|
+
const fn = [...call.arguments].reverse().find(a => ts.isArrowFunction(a) || ts.isFunctionExpression(a))
|
|
129
|
+
// `fit` with no test body is someone's function (@weft-js/core/text's), not a focused test
|
|
130
|
+
if (root === 'fit' && !fn) return null
|
|
131
|
+
return { root, rootNode: n, mods, fn, suite: suiteRoots.has(root), call }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Functions (by name) whose bodies assert, in one file: local helpers and, given `imported`, what they call. */
|
|
135
|
+
function assertingFunctions(sf, imported = new Set()) {
|
|
136
|
+
const bodies = new Map()
|
|
137
|
+
const add = (name, fn) => fn?.body && bodies.set(name, fn.body)
|
|
138
|
+
for (const st of sf.statements) {
|
|
139
|
+
const decl = ts.isExportDeclaration(st) ? null : st
|
|
140
|
+
if (decl && ts.isFunctionDeclaration(decl) && decl.name) add(decl.name.text, decl)
|
|
141
|
+
if (decl && ts.isVariableStatement(decl))
|
|
142
|
+
for (const d of decl.declarationList.declarations) if (ts.isIdentifier(d.name) && isFn(d.initializer)) add(d.name.text, d.initializer)
|
|
143
|
+
}
|
|
144
|
+
const asserting = new Set(imported)
|
|
145
|
+
for (let changed = true; changed; ) {
|
|
146
|
+
changed = false
|
|
147
|
+
for (const [name, body] of bodies) {
|
|
148
|
+
if (asserting.has(name) || !asserts(body, asserting)) continue
|
|
149
|
+
asserting.add(name)
|
|
150
|
+
changed = true
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return asserting
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Does this code assert? A matcher, `assert…`, `expect.assertions`, a helper named `expect…`, or a known asserting function. */
|
|
157
|
+
function asserts(node, asserting) {
|
|
158
|
+
return contains(node, n => {
|
|
159
|
+
if (!ts.isCallExpression(n)) return false
|
|
160
|
+
if (isSubject(n)) return Boolean(matcherOf(n))
|
|
161
|
+
const c = n.expression
|
|
162
|
+
if (ts.isPropertyAccessExpression(c) && ts.isIdentifier(c.expression) && c.expression.text === 'expect') return true // expect.assertions(n)
|
|
163
|
+
const name = ts.isIdentifier(c) ? c.text : ts.isPropertyAccessExpression(c) && ts.isIdentifier(c.expression) ? c.expression.text : undefined
|
|
164
|
+
return Boolean(name && (/^(expect|assert)([A-Z_]|$)/.test(name) || asserting.has(name)))
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function resolveImport(fromAbs, spec) {
|
|
169
|
+
const base = local(fromAbs, spec)
|
|
170
|
+
if (!base) return null
|
|
171
|
+
for (const e of exts) {
|
|
172
|
+
const p = base + e
|
|
173
|
+
if (existsSync(p) && !p.endsWith('/') && (e || /\.[cm]?[jt]sx?$/.test(p))) return p
|
|
174
|
+
}
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const moduleKey = abs => abs.replace(/(\/index)?\.[cm]?[jt]sx?$/, '')
|
|
179
|
+
|
|
180
|
+
/** Comment directives: `// weft-allow T004, T005: reason`. */
|
|
181
|
+
export function allowances(text, sf) {
|
|
182
|
+
const firstCode = sf.statements[0]?.getStart(sf) ?? text.length
|
|
183
|
+
const file = new Set()
|
|
184
|
+
const lines = new Map()
|
|
185
|
+
const pos = positions(text)
|
|
186
|
+
for (const m of text.matchAll(/\/\/[ \t]*weft-allow[ \t]+((?:T\d{3}[ \t,]*)+)(?::[ \t]*(\S.*))?$/gm)) {
|
|
187
|
+
if (!m[2]?.trim()) continue
|
|
188
|
+
const codes = m[1].match(/T\d{3}/g)
|
|
189
|
+
if (m.index < firstCode) for (const c of codes) file.add(c)
|
|
190
|
+
else {
|
|
191
|
+
const [line] = pos.lineCol(m.index)
|
|
192
|
+
for (const l of [line, line + 1]) for (const c of codes) lines.set(`${c}:${l}`, true)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return (code, line) => file.has(code) || lines.has(`${code}:${line}`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* T-codes for one test file.
|
|
200
|
+
* @param {string} text
|
|
201
|
+
* @param {string} rel path relative to the project root (for diagnostics)
|
|
202
|
+
* @param {{ abs?: string, root?: string }} [options] `abs` lets T001/T006 read the helpers and units the file imports
|
|
203
|
+
*/
|
|
204
|
+
export function checkTestFile(text, rel, options = {}) {
|
|
205
|
+
const abs = options.abs ?? rel
|
|
206
|
+
const sf = parse(text, rel)
|
|
207
|
+
const pos = positions(text)
|
|
208
|
+
const allowed = allowances(text, sf)
|
|
209
|
+
const out = []
|
|
210
|
+
const seen = new Set()
|
|
211
|
+
const report = (code, node, why, extra = {}) => {
|
|
212
|
+
const start = node.getStart(sf)
|
|
213
|
+
const end = node.getEnd()
|
|
214
|
+
const span = pos.span(start, end)
|
|
215
|
+
const key = `${code}:${span[0]}:${span[1]}`
|
|
216
|
+
if (allowed(code, span[0]) || seen.has(key)) return
|
|
217
|
+
seen.add(key)
|
|
218
|
+
out.push(diagnostic({ code, file: rel, span, offset: [start, end], why, ...extra }))
|
|
219
|
+
}
|
|
220
|
+
const edit = (start, end, newText) => ({ file: rel, span: pos.span(start, end), offset: [start, end], text: newText })
|
|
221
|
+
|
|
222
|
+
// ---- what the file imports -----------------------------------------------------------------------------------
|
|
223
|
+
const imports = new Map() // local name → { spec, name, abs }
|
|
224
|
+
const namespaces = new Map() // `import * as x` → spec
|
|
225
|
+
for (const st of sf.statements) {
|
|
226
|
+
if (!ts.isImportDeclaration(st) || !st.importClause || !ts.isStringLiteral(st.moduleSpecifier)) continue
|
|
227
|
+
const spec = st.moduleSpecifier.text
|
|
228
|
+
const target = resolveImport(abs, spec)
|
|
229
|
+
if (st.importClause.name) imports.set(st.importClause.name.text, { spec, name: 'default', abs: target })
|
|
230
|
+
const nb = st.importClause.namedBindings
|
|
231
|
+
if (nb && ts.isNamespaceImport(nb)) namespaces.set(nb.name.text, { spec, abs: target })
|
|
232
|
+
if (nb && ts.isNamedImports(nb)) for (const el of nb.elements) imports.set(el.name.text, { spec, name: (el.propertyName ?? el.name).text, abs: target })
|
|
233
|
+
}
|
|
234
|
+
const helperAsserting = new Set()
|
|
235
|
+
const helperCache = new Map()
|
|
236
|
+
for (const [binding, imp] of imports) {
|
|
237
|
+
if (!imp.abs || /\.(test|spec)\./.test(imp.abs)) continue
|
|
238
|
+
if (!helperCache.has(imp.abs)) {
|
|
239
|
+
let names = new Set()
|
|
240
|
+
try {
|
|
241
|
+
const src = readFileSync(imp.abs, 'utf8')
|
|
242
|
+
names = assertingFunctions(parse(src, imp.abs))
|
|
243
|
+
} catch {}
|
|
244
|
+
helperCache.set(imp.abs, names)
|
|
245
|
+
}
|
|
246
|
+
if (helperCache.get(imp.abs).has(imp.name)) helperAsserting.add(binding)
|
|
247
|
+
}
|
|
248
|
+
const asserting = assertingFunctions(sf, helperAsserting)
|
|
249
|
+
|
|
250
|
+
// ---- what the file tests: components it renders, and the modules they come from --------------------------------
|
|
251
|
+
const renders = new Set()
|
|
252
|
+
walk(sf, n => {
|
|
253
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'render' && n.arguments[0]) {
|
|
254
|
+
const first = unwrap(n.arguments[0])
|
|
255
|
+
if (ts.isIdentifier(first)) renders.add(first.text)
|
|
256
|
+
walk(first, j => {
|
|
257
|
+
if ((ts.isJsxOpeningElement(j) || ts.isJsxSelfClosingElement(j)) && ts.isIdentifier(j.tagName)) renders.add(j.tagName.text)
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
const isComponentTest = contains(sf, n => ts.isCallExpression(n) && ts.isIdentifier(n.expression) && ['render', 'mount', 'hydrate'].includes(n.expression.text))
|
|
262
|
+
const units = new Set()
|
|
263
|
+
const unitSpecs = new Set()
|
|
264
|
+
const specKey = spec => spec.replace(/(\/index)?\.[cm]?[jt]sx?$/, '')
|
|
265
|
+
for (const name of renders) {
|
|
266
|
+
const imp = imports.get(name)
|
|
267
|
+
if (imp?.abs) units.add(moduleKey(imp.abs))
|
|
268
|
+
if (imp && local(abs, imp.spec)) unitSpecs.add(specKey(imp.spec))
|
|
269
|
+
}
|
|
270
|
+
const own = resolveImport(abs, `./${relative(dirname(abs), abs).replace(/\.(test|spec)\.[cm]?[jt]sx?$/, '')}`)
|
|
271
|
+
if (own) units.add(moduleKey(own))
|
|
272
|
+
const isUnitSpec = spec => {
|
|
273
|
+
const t = resolveImport(abs, spec)
|
|
274
|
+
return t ? units.has(moduleKey(t)) : unitSpecs.has(specKey(spec))
|
|
275
|
+
}
|
|
276
|
+
const isWeftSpec = spec => spec === '@weft-js/core' || spec.startsWith('@weft-js/core/') || spec.startsWith('@weft-js/')
|
|
277
|
+
const fakeTimers = contains(sf, n => ts.isPropertyAccessExpression(n) && (n.name.text === 'useFakeTimers' || (n.name.text === 'install' && rootName(n) === 'clock')))
|
|
278
|
+
|
|
279
|
+
// ---- per test: T001, T003, T008, T009 ---------------------------------------------------------------------------
|
|
280
|
+
const tests = []
|
|
281
|
+
walk(sf, n => {
|
|
282
|
+
if (!ts.isCallExpression(n)) return
|
|
283
|
+
const t = testCall(n)
|
|
284
|
+
if (t) tests.push(t)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
for (const t of tests) {
|
|
288
|
+
const focus = t.mods.find(m => m.name === 'only') ?? (focusRoots[t.root] && { name: 'only', node: t.rootNode })
|
|
289
|
+
const skip = t.mods.find(m => m.name === 'skip') ?? (skipRoots[t.root] && { name: 'skip', node: t.rootNode })
|
|
290
|
+
for (const m of [focus, skip].filter(Boolean)) {
|
|
291
|
+
const kind = t.suite ? 'suite' : 'test'
|
|
292
|
+
let fix
|
|
293
|
+
if (m.node === t.rootNode) {
|
|
294
|
+
const to = focusRoots[t.root] ?? skipRoots[t.root]
|
|
295
|
+
fix = { description: `Rename \`${t.root}\` to \`${to}\``, safe: m.name === 'only', edits: [edit(m.node.getStart(sf), m.node.getEnd(), to)] }
|
|
296
|
+
report('T009', m.node, `\`${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'}.`, { fix })
|
|
297
|
+
} else {
|
|
298
|
+
const dot = m.node.expression.getEnd()
|
|
299
|
+
fix = { description: `Remove \`.${m.name}\``, safe: m.name === 'only', edits: [edit(dot, m.node.getEnd(), '')] }
|
|
300
|
+
report('T009', m.node.name, m.name === 'only'
|
|
301
|
+
? `\`.only\` focuses this ${kind}: every other test in the run is silently skipped.`
|
|
302
|
+
: `\`.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\`.`, { fix })
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (t.suite || !t.fn || t.mods.some(m => ['todo', 'skip'].includes(m.name)) || skipRoots[t.root]) continue
|
|
306
|
+
const body = t.fn.body
|
|
307
|
+
|
|
308
|
+
const noAssertion = !asserts(body, asserting)
|
|
309
|
+
if (noAssertion) {
|
|
310
|
+
const title = t.call.arguments[0] && ts.isStringLiteralLike(t.call.arguments[0]) ? ` "${t.call.arguments[0].text}"` : ''
|
|
311
|
+
report('T001', t.call.expression, `Test${title} makes no assertion: it passes as long as nothing throws.`)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// A looped `expect` is fine next to one that always runs; a conditional one never is.
|
|
315
|
+
const looped = []
|
|
316
|
+
let always = false
|
|
317
|
+
walk(body, n => {
|
|
318
|
+
if (!ts.isCallExpression(n) || !isSubject(n)) return
|
|
319
|
+
const m = matcherOf(n)
|
|
320
|
+
if (!m) {
|
|
321
|
+
if (!noAssertion) report('T001', n, `\`${norm(n)}\` has no matcher, so it asserts nothing.`, { help: 'Finish it: `expect(x).toBe(…)`, `.toHaveTextContent(…)`, …' })
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
let why
|
|
325
|
+
for (let a = n.parent; a && a !== t.fn && !why; a = a.parent) why = conditional(a, n)
|
|
326
|
+
if (!why) always = true
|
|
327
|
+
else if (why.loop) looped.push([m.call, why.text])
|
|
328
|
+
else report('T008', m.call, `This \`expect\` only runs ${why.text}, so the test can pass without checking anything.`)
|
|
329
|
+
})
|
|
330
|
+
if (!always) for (const [call, how] of looped) report('T008', call, `Every \`expect\` in this test runs ${how}, so the test can pass without checking anything.`)
|
|
331
|
+
|
|
332
|
+
if (ts.isBlock(body)) assertsWhatItSet(body.statements, report)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---- whole file: T002, T004, T005, T006, T007 ---------------------------------------------------------------------
|
|
336
|
+
const renderResults = new Set()
|
|
337
|
+
walk(sf, n => {
|
|
338
|
+
if (ts.isVariableDeclaration(n) && n.initializer && ts.isCallExpression(unwrap(n.initializer)) && rootName(unwrap(n.initializer).expression) === 'render') {
|
|
339
|
+
if (ts.isIdentifier(n.name)) renderResults.add(n.name.text)
|
|
340
|
+
if (ts.isObjectBindingPattern(n.name))
|
|
341
|
+
for (const el of n.name.elements) if ((el.propertyName ?? el.name).getText() === 'container') report('T005', el, '`container` reaches into the DOM tree the user never sees.')
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
walk(sf, n => {
|
|
346
|
+
if (ts.isCallExpression(n)) {
|
|
347
|
+
const c = n.expression
|
|
348
|
+
const name = ts.isIdentifier(c) ? c.text : ts.isPropertyAccessExpression(c) ? c.name.text : undefined
|
|
349
|
+
|
|
350
|
+
if (isSubject(n)) {
|
|
351
|
+
const m = matcherOf(n)
|
|
352
|
+
const subject = n.arguments[0]
|
|
353
|
+
if (m && subject) {
|
|
354
|
+
if (isLiteral(subject)) report('T002', n, `\`${norm(n)}\` checks a literal, so it can never fail.`)
|
|
355
|
+
else if (sameValueMatchers.has(m.name) && !m.not && m.call.arguments[0] && norm(m.call.arguments[0]) === norm(subject) && !hasCall(subject))
|
|
356
|
+
report('T002', m.call, `\`${norm(m.call)}\` compares \`${norm(subject)}\` with itself, so it can never fail.`)
|
|
357
|
+
else if (ts.isBinaryExpression(unwrap(subject)) && isComparison(unwrap(subject)) && norm(unwrap(subject).left) === norm(unwrap(subject).right) && !hasCall(subject))
|
|
358
|
+
report('T002', n, `\`${norm(subject)}\` compares a value with itself, so it can never fail.`)
|
|
359
|
+
if (snapshotMatchers.has(m.name))
|
|
360
|
+
report('T007', m.node, `\`${m.name}\` records whatever the code does today and passes against it: it pins output, not behavior.`)
|
|
361
|
+
if (m.name === 'toHaveClass') report('T005', m.node, '`toHaveClass` asserts on styling classes, not on what the user sees.')
|
|
362
|
+
if (contains(subject, x => ts.isPropertyAccessExpression(x) && ['className', 'classList', 'innerHTML', 'outerHTML'].includes(x.name.text))) {
|
|
363
|
+
const prop = []
|
|
364
|
+
walk(subject, x => { if (ts.isPropertyAccessExpression(x) && ['className', 'classList', 'innerHTML', 'outerHTML'].includes(x.name.text)) prop.push(x.name.text) })
|
|
365
|
+
report('T005', subject, `Asserting on \`${prop[0]}\` pins markup and classes, not what the user sees.`)
|
|
366
|
+
}
|
|
367
|
+
if (isComponentTest && readsInternalState(subject, imports)) report('T005', subject, `\`${norm(subject)}\` reads the component's state directly instead of what it renders.`)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (name && domQueries.has(name) && ts.isPropertyAccessExpression(c)) report('T005', c.name, `\`${name}\` queries by CSS structure, which users never see.`)
|
|
372
|
+
if (name && /^(get|query|find)(All)?ByTestId$/.test(name)) report('T005', ts.isPropertyAccessExpression(c) ? c.name : c, `\`${name}\` finds the element by a test id the user never sees.`)
|
|
373
|
+
|
|
374
|
+
// T004: sleeps, real timers and polling.
|
|
375
|
+
if (name === 'waitFor' || name === 'waitForElementToBeRemoved')
|
|
376
|
+
report('T004', c, `\`${name}\` polls on real timers and guesses how long the update takes.`, { help: '`await user.click(…)` and `await flush()` settle every update; there is nothing to wait for' })
|
|
377
|
+
if (ts.isIdentifier(c) && ['sleep', 'delay'].includes(c.text) && ts.isAwaitExpression(n.parent))
|
|
378
|
+
report('T004', n, `\`${norm(n)}\` sleeps on the real clock: slow, and flaky when the machine is busy.`)
|
|
379
|
+
if (name && timerCalls.has(name) && (ts.isIdentifier(c) || ['window', 'globalThis', 'global'].includes(rootName(c)))) {
|
|
380
|
+
const sleep = sleepPromise(n)
|
|
381
|
+
if (sleep) {
|
|
382
|
+
const delay = n.arguments[1]
|
|
383
|
+
const zero = !delay || (ts.isNumericLiteral(delay) && Number(delay.text) === 0)
|
|
384
|
+
const fix = zero ? flushFix(sf, sleep, edit) : undefined
|
|
385
|
+
report('T004', sleep, `\`${norm(sleep)}\` sleeps on the real clock: slow, and flaky when the machine is busy.`, {
|
|
386
|
+
help: zero ? 'Use `await flush()`: it settles every pending update' : `Install the fake clock (\`clock.install()\`) and use \`await clock.advance(${delay.getText()})\``,
|
|
387
|
+
fix,
|
|
388
|
+
})
|
|
389
|
+
} else if (!fakeTimers && !insideFakeApi(n))
|
|
390
|
+
report('T004', c, `\`${name}\` runs on the real clock in a test.`, { help: 'Call `clock.install()` from @weft-js/core/test first and drive time with `await clock.advance(ms)`' })
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// T006: mocking weft or the unit under test.
|
|
394
|
+
if (name && ['mock', 'doMock'].includes(name) && ['vi', 'jest'].includes(rootName(c)) && n.arguments[0] && ts.isStringLiteralLike(n.arguments[0])) {
|
|
395
|
+
const spec = n.arguments[0].text
|
|
396
|
+
if (isWeftSpec(spec)) report('T006', n, `Mocking \`${spec}\` replaces the framework the test is supposed to exercise.`)
|
|
397
|
+
else if (isUnitSpec(spec)) report('T006', n, `Mocking \`${spec}\` replaces the unit under test, so the test checks the mock.`)
|
|
398
|
+
}
|
|
399
|
+
if (name === 'spyOn' && ['vi', 'jest'].includes(rootName(c)) && n.arguments[0] && ts.isIdentifier(n.arguments[0])) {
|
|
400
|
+
const ns = namespaces.get(n.arguments[0].text)
|
|
401
|
+
if (ns && isWeftSpec(ns.spec)) report('T006', n, `Spying on \`${ns.spec}\` replaces the framework the test is supposed to exercise.`)
|
|
402
|
+
else if (ns && isUnitSpec(ns.spec)) report('T006', n, `Spying on \`${ns.spec}\` replaces the unit under test, so the test checks the mock.`)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// T013: a real socket in the fast lane.
|
|
407
|
+
if (ts.isNewExpression(n) && ts.isIdentifier(n.expression) && ['WebSocket', 'EventSource'].includes(n.expression.text))
|
|
408
|
+
report('T013', n, `\`new ${n.expression.text}\` opens a real socket in a fast test.`, { help: "Render the `.live()` or stream against @weft-js/core/test's fake transport: `await net.push(stream, message)`, `await net.poke('tag')`, `await net.drop()`" })
|
|
409
|
+
|
|
410
|
+
if (ts.isPropertyAccessExpression(n) && n.name.text === 'container' && !(ts.isCallExpression(n.parent) && n.parent.expression === n)) {
|
|
411
|
+
const obj = unwrap(n.expression)
|
|
412
|
+
const fromRender = (ts.isCallExpression(obj) && rootName(obj.expression) === 'render') || (ts.isIdentifier(obj) && renderResults.has(obj.text))
|
|
413
|
+
if (fromRender) report('T005', n, '`container` reaches into the DOM tree the user never sees.')
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
return out
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const isComparison = b => [ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.EqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken].includes(b.operatorToken.kind)
|
|
421
|
+
|
|
422
|
+
/** Why `node`, under ancestor `a`, only runs sometimes (`loop`: maybe zero times); undefined when it always runs. */
|
|
423
|
+
function conditional(a, node) {
|
|
424
|
+
const within = x => x && node.getStart() >= x.getStart() && node.getEnd() <= x.getEnd()
|
|
425
|
+
const when = text => ({ text })
|
|
426
|
+
const loop = text => ({ text, loop: true })
|
|
427
|
+
if (ts.isIfStatement(a) && !within(a.expression)) return when('when the `if` matches')
|
|
428
|
+
if (ts.isConditionalExpression(a) && !within(a.condition)) return when('on one side of a `?:`')
|
|
429
|
+
if (ts.isBinaryExpression(a) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken, ts.SyntaxKind.QuestionQuestionToken].includes(a.operatorToken.kind) && within(a.right)) return when(`after a \`${a.operatorToken.getText()}\``)
|
|
430
|
+
if (ts.isCaseClause(a) || ts.isDefaultClause(a)) return when('in one `switch` case')
|
|
431
|
+
if (ts.isCatchClause(a)) return when('when something throws')
|
|
432
|
+
if ((ts.isForStatement(a) || ts.isForOfStatement(a) || ts.isForInStatement(a) || ts.isWhileStatement(a) || ts.isDoStatement(a)) && within(a.statement)) return loop('inside a loop, and zero iterations check nothing')
|
|
433
|
+
if (isFn(a) && ts.isCallExpression(a.parent) && ts.isPropertyAccessExpression(a.parent.expression) && loopCallbacks.has(a.parent.expression.name.text) && a.parent.arguments.includes(a))
|
|
434
|
+
return loop(`inside \`.${a.parent.expression.name.text}\`, and an empty list checks nothing`)
|
|
435
|
+
return undefined
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** `expect(cart.items.value)` with `cart` imported from app code, in a component test. (`scope(cart)` from @weft-js/core/test is the way to test a scope.) */
|
|
439
|
+
function readsInternalState(subject, imports) {
|
|
440
|
+
const s = unwrap(subject)
|
|
441
|
+
if (!ts.isPropertyAccessExpression(s) || s.name.text !== 'value') return false
|
|
442
|
+
let n = s.expression
|
|
443
|
+
for (;;) {
|
|
444
|
+
n = unwrap(n)
|
|
445
|
+
if (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) n = n.expression
|
|
446
|
+
else break
|
|
447
|
+
}
|
|
448
|
+
return ts.isIdentifier(n) && Boolean(imports.get(n.text)?.abs)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** `new Promise(r => setTimeout(r, ms))`: the timer call's enclosing sleep, or undefined. */
|
|
452
|
+
function sleepPromise(timer) {
|
|
453
|
+
const cb = timer.arguments[0]
|
|
454
|
+
let exec = timer.parent
|
|
455
|
+
while (exec && (ts.isExpressionStatement(exec) || ts.isBlock(exec))) exec = exec.parent
|
|
456
|
+
if (!isFn(exec) || !ts.isNewExpression(exec.parent) || exec.parent.expression.getText() !== 'Promise') return undefined
|
|
457
|
+
const resolveName = exec.parameters[0]?.name.getText()
|
|
458
|
+
const callsResolve = cb && (cb.getText() === resolveName || (isFn(cb) && contains(cb.body, x => ts.isCallExpression(x) && x.expression.getText() === resolveName)))
|
|
459
|
+
if (!callsResolve) return undefined
|
|
460
|
+
// A fake API's latency resolves with data (`ok(user)`); a sleep resolves with nothing.
|
|
461
|
+
if (isFn(cb) && contains(cb.body, x => ts.isCallExpression(x) && x.expression.getText() === resolveName && x.arguments.length > 0)) return undefined
|
|
462
|
+
return exec.parent
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Timers inside a function that a fake API returns (`load: id => new Promise(ok => setTimeout(() => ok(x), ms))`). */
|
|
466
|
+
function insideFakeApi(timer) {
|
|
467
|
+
for (let n = timer.parent; n; n = n.parent) {
|
|
468
|
+
if (ts.isNewExpression(n) && n.expression.getText() === 'Promise') return true
|
|
469
|
+
}
|
|
470
|
+
return false
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Replace a zero-delay sleep with `flush()`, importing it from @weft-js/core/test when the file doesn't. */
|
|
474
|
+
function flushFix(sf, sleep, edit) {
|
|
475
|
+
const edits = [edit(sleep.getStart(sf), sleep.getEnd(), 'flush()')]
|
|
476
|
+
const hasFlush = sf.statements.some(st => ts.isImportDeclaration(st) && st.importClause?.namedBindings && ts.isNamedImports(st.importClause.namedBindings) && st.importClause.namedBindings.elements.some(e => e.name.text === 'flush'))
|
|
477
|
+
if (!hasFlush) {
|
|
478
|
+
const weftTest = sf.statements.find(st => ts.isImportDeclaration(st) && ts.isStringLiteral(st.moduleSpecifier) && st.moduleSpecifier.text === '@weft-js/core/test' && st.importClause?.namedBindings && ts.isNamedImports(st.importClause.namedBindings))
|
|
479
|
+
if (weftTest) {
|
|
480
|
+
const el = weftTest.importClause.namedBindings.elements
|
|
481
|
+
edits.push(edit(el[el.length - 1].getEnd(), el[el.length - 1].getEnd(), ', flush'))
|
|
482
|
+
} else {
|
|
483
|
+
const last = [...sf.statements].reverse().find(st => ts.isImportDeclaration(st))
|
|
484
|
+
const at = last ? last.getEnd() : 0
|
|
485
|
+
edits.push(edit(at, at, `${last ? '\n' : ''}import { flush } from '@weft-js/core/test'${last ? '' : '\n'}`))
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return { description: 'Replace the sleep with `flush()`', safe: false, edits }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* T003 in one test body: `n.value = 5; expect(n.value).toBe(5)`, or `const s = state(3); expect(s.value).toBe(3)`.
|
|
493
|
+
* Any call between the write and the assertion (a click, a render, a fake push) may route through the app, so it ends
|
|
494
|
+
* the check; `flush()` doesn't.
|
|
495
|
+
*/
|
|
496
|
+
function assertsWhatItSet(statements, report) {
|
|
497
|
+
const writes = new Map()
|
|
498
|
+
const boxes = new Set() // `const n = state(…)`: a plain box, so a write reads straight back
|
|
499
|
+
const locals = new Set()
|
|
500
|
+
const isFlush = st => {
|
|
501
|
+
const e = ts.isExpressionStatement(st) ? unwrap(st.expression) : null
|
|
502
|
+
return e && ts.isCallExpression(e) && e.expression.getText() === 'flush'
|
|
503
|
+
}
|
|
504
|
+
for (const st of statements) {
|
|
505
|
+
if (ts.isVariableStatement(st)) {
|
|
506
|
+
for (const d of st.declarationList.declarations) {
|
|
507
|
+
if (!ts.isIdentifier(d.name) || !d.initializer) continue
|
|
508
|
+
const init = unwrap(d.initializer)
|
|
509
|
+
locals.add(d.name.text)
|
|
510
|
+
if (ts.isCallExpression(init) && init.expression.getText() === 'state') boxes.add(d.name.text)
|
|
511
|
+
if (isLiteral(init)) writes.set(d.name.text, init)
|
|
512
|
+
else if (ts.isCallExpression(init) && init.expression.getText() === 'state' && init.arguments[0] && isLiteral(init.arguments[0])) writes.set(`${d.name.text}.value`, init.arguments[0])
|
|
513
|
+
else if (hasCall(init)) writes.clear()
|
|
514
|
+
}
|
|
515
|
+
continue
|
|
516
|
+
}
|
|
517
|
+
if (isFlush(st)) continue
|
|
518
|
+
const e = ts.isExpressionStatement(st) ? unwrap(st.expression) : null
|
|
519
|
+
if (e && ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.EqualsToken && !hasCall(e.left)) {
|
|
520
|
+
const target = norm(e.left)
|
|
521
|
+
const plain = (ts.isIdentifier(e.left) && locals.has(target)) || (ts.isPropertyAccessExpression(e.left) && e.left.name.text === 'value' && boxes.has(norm(e.left.expression)))
|
|
522
|
+
if (isLiteral(e.right) && plain) writes.set(target, e.right)
|
|
523
|
+
else writes.delete(target)
|
|
524
|
+
if (hasCall(e.right)) writes.clear()
|
|
525
|
+
continue
|
|
526
|
+
}
|
|
527
|
+
const subject = e && findSubject(e)
|
|
528
|
+
if (subject) {
|
|
529
|
+
const m = matcherOf(subject)
|
|
530
|
+
const written = subject.arguments[0] && writes.get(norm(subject.arguments[0]))
|
|
531
|
+
if (m && !m.not && sameValueMatchers.has(m.name) && written && m.call.arguments[0] && norm(m.call.arguments[0]) === norm(written))
|
|
532
|
+
report('T003', m.call, `The test set \`${norm(subject.arguments[0])}\` to \`${norm(written)}\` itself and asserts it back: nothing between went through the UI or the graph.`)
|
|
533
|
+
continue
|
|
534
|
+
}
|
|
535
|
+
writes.clear()
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** The subject call of a statement that is one assertion chain (`expect(a).toBe(b)`), else undefined. */
|
|
540
|
+
function findSubject(e) {
|
|
541
|
+
let n = unwrap(e)
|
|
542
|
+
for (;;) {
|
|
543
|
+
if (ts.isCallExpression(n) && isSubject(n)) return n
|
|
544
|
+
if (ts.isCallExpression(n) || ts.isPropertyAccessExpression(n)) n = unwrap(n.expression)
|
|
545
|
+
else return undefined
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* T-codes for the project's test files.
|
|
551
|
+
* @param {string} root absolute project root
|
|
552
|
+
* @param {string[]} rels test files, relative to `root`
|
|
553
|
+
*/
|
|
554
|
+
export function checkTests(root, rels) {
|
|
555
|
+
return rels.flatMap(rel => {
|
|
556
|
+
const abs = join(root, rel)
|
|
557
|
+
return checkTestFile(readFileSync(abs, 'utf8'), rel, { abs })
|
|
558
|
+
})
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export const isTestFile = rel => /\.(test|spec)\.[cm]?[jt]sx?$/.test(rel)
|
|
562
|
+
|
|
563
|
+
/** Which T-codes this pass reports: each catalog example must produce its own code (the T side of `probe()`). */
|
|
564
|
+
export function probeTests(catalog) {
|
|
565
|
+
const out = {}
|
|
566
|
+
for (const e of catalog) {
|
|
567
|
+
if (!/^T\d{3}$/.test(e.code) || e.by !== 'cli') continue // T012 is @weft-js/core/test's runtime check
|
|
568
|
+
const hit = e.example && checkTestFile(e.example, 'probe.test.tsx').find(d => d.code === e.code)
|
|
569
|
+
out[e.code] = { implemented: Boolean(hit), autofix: hit?.fix?.safe === true, suggestion: Boolean(hit?.fix?.edits) && hit.fix.safe !== true }
|
|
570
|
+
}
|
|
571
|
+
return out
|
|
572
|
+
}
|