@weft-js/cli 0.1.1 → 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 +33 -8
- package/docs/design.md +12 -3
- package/docs/entrypoints.md +79 -0
- package/docs/fonts.md +12 -2
- package/docs/icons.md +13 -7
- package/docs/lists.md +3 -1
- package/docs/meta.md +13 -3
- package/docs/plugins.md +32 -9
- package/docs/props.md +13 -2
- package/docs/reactivity.md +26 -0
- package/docs/rules.md +19 -12
- package/docs/rust.md +47 -17
- package/docs/start-short.md +3 -2
- package/docs/start.md +226 -52
- package/docs/state.md +33 -7
- package/docs/style.md +31 -8
- package/docs/surface.md +61 -15
- package/docs/testing.md +15 -5
- package/docs/theme.md +182 -0
- package/docs/ui-state.md +19 -6
- package/docs/verify.md +13 -0
- package/package.json +13 -4
- package/src/add/config.mjs +93 -0
- package/src/add/errors.mjs +2 -0
- package/src/add/setup.mjs +88 -0
- package/src/add-rust.mjs +82 -27
- package/src/add.mjs +209 -181
- package/src/agents-md.mjs +144 -43
- package/src/alias.mjs +16 -5
- package/src/audit/page.mjs +313 -0
- package/src/audit/report.mjs +69 -0
- package/src/audit/utils.mjs +5 -0
- package/src/audit.mjs +159 -206
- package/src/browser/cli.mjs +24 -5
- package/src/browser/client.mjs +189 -25
- package/src/browser/commands.mjs +166 -51
- package/src/catalog/audit.mjs +129 -0
- package/src/catalog/auth.mjs +40 -0
- package/src/catalog/compiler.mjs +337 -0
- package/src/catalog/contracts.mjs +18 -0
- package/src/catalog/database.mjs +52 -0
- package/src/catalog/deploy.mjs +117 -0
- package/src/catalog/design.mjs +159 -0
- package/src/catalog/forms.mjs +25 -0
- package/src/catalog/hydration.mjs +29 -0
- package/src/catalog/icons.mjs +24 -0
- package/src/catalog/media.mjs +163 -0
- package/src/catalog/metadata.mjs +116 -0
- package/src/catalog/plugins.mjs +228 -0
- package/src/catalog/realtime.mjs +195 -0
- package/src/catalog/rust.mjs +75 -0
- package/src/catalog/server-functions.mjs +92 -0
- package/src/catalog/state.mjs +39 -0
- package/src/catalog/style-groups.mjs +54 -0
- package/src/catalog/tests.mjs +127 -0
- package/src/catalog/text.mjs +200 -0
- package/src/catalog/ui-state.mjs +42 -0
- package/src/catalog.mjs +67 -460
- package/src/check/runtime.mjs +280 -0
- package/src/check.mjs +198 -208
- package/src/cli/authoring.mjs +213 -0
- package/src/cli/project.mjs +302 -0
- package/src/cli.mjs +222 -250
- package/src/config.mjs +1 -1
- package/src/contributions.mjs +75 -25
- package/src/db-layer.mjs +13 -5
- package/src/deploy/cdn.mjs +94 -16
- package/src/deploy/files.mjs +100 -21
- package/src/deploy/fit.mjs +127 -23
- package/src/deploy/hosts.mjs +90 -20
- package/src/deploy/index/cli.mjs +20 -0
- package/src/deploy/index/environment.mjs +69 -0
- package/src/deploy/index/steps.mjs +87 -0
- package/src/deploy/index.mjs +228 -114
- package/src/deploy/needs.mjs +88 -31
- package/src/design-checks/accessible-names.mjs +62 -0
- package/src/design-checks/contrast.mjs +43 -0
- package/src/design-checks/csp.mjs +60 -0
- package/src/design-checks/files.mjs +59 -0
- package/src/design-checks/heading-links.mjs +61 -0
- package/src/design-checks/interaction.mjs +65 -0
- package/src/design-checks/jsx.mjs +67 -0
- package/src/design-checks/layout-values.mjs +68 -0
- package/src/design-checks/native-controls.mjs +48 -0
- package/src/design-checks/outline.mjs +39 -0
- package/src/design-checks/reporter.mjs +37 -0
- package/src/design-checks/rules.mjs +274 -0
- package/src/design-checks/values.mjs +262 -0
- package/src/design-checks.mjs +10 -400
- package/src/diag.mjs +32 -8
- package/src/docs.mjs +138 -43
- package/src/doctor.mjs +20 -8
- package/src/files.mjs +18 -5
- package/src/fix.mjs +10 -2
- package/src/gate/after.mjs +40 -0
- package/src/gate/before.mjs +54 -0
- package/src/gate/classify.mjs +72 -0
- package/src/gate/mutation.mjs +209 -0
- package/src/gate/resolve-cases.mjs +22 -0
- package/src/gate/result.mjs +63 -0
- package/src/gate/run.mjs +75 -0
- package/src/gate.mjs +104 -202
- package/src/infer/box-props.cjs +140 -0
- package/src/infer/core-boxes.cjs +46 -0
- package/src/infer/derived-writes.cjs +95 -0
- package/src/infer/engine/checks.cjs +193 -0
- package/src/infer/engine/flow.cjs +293 -0
- package/src/infer/engine/report.cjs +158 -0
- package/src/infer/engine/utils.cjs +15 -0
- package/src/infer/engine.cjs +108 -368
- package/src/infer/facts/sites.cjs +114 -0
- package/src/infer/facts.cjs +210 -117
- package/src/infer/hosts.cjs +69 -22
- package/src/infer/lsp.mjs +149 -40
- package/src/infer/session.cjs +73 -17
- package/src/infer/shadow.cjs +13 -5
- package/src/infer/worker.cjs +57 -9
- package/src/jsx-scan.mjs +63 -21
- package/src/load-config/schema.mjs +281 -0
- package/src/load-config.mjs +137 -138
- package/src/map.mjs +93 -26
- package/src/mcp/browser-tools.mjs +155 -0
- package/src/mcp.mjs +147 -69
- package/src/meta-table.mjs +43 -8
- package/src/mutate.mjs +120 -36
- package/src/plugin-checks.mjs +52 -23
- package/src/plugins.mjs +125 -39
- package/src/project.mjs +170 -79
- package/src/props-checks.mjs +24 -6
- package/src/props-listener.mjs +27 -9
- package/src/react-checks.mjs +35 -4
- package/src/server-state-checks.mjs +34 -0
- package/src/share-checks.mjs +121 -37
- package/src/site.mjs +33 -6
- package/src/slots.mjs +104 -19
- package/src/start/build.mjs +116 -0
- package/src/start/routes.mjs +38 -0
- package/src/start.mjs +193 -95
- package/src/state-checks/core-state.mjs +223 -0
- package/src/state-checks/modules.mjs +17 -0
- package/src/state-checks/scan.mjs +232 -0
- package/src/state-checks/server-graph.mjs +65 -0
- package/src/state-checks.mjs +109 -133
- package/src/style/find.mjs +121 -0
- package/src/style.mjs +133 -127
- package/src/surface-checks.mjs +212 -46
- package/src/tailwind.mjs +5 -4
- package/src/tasks.mjs +189 -27
- package/src/test-rules/context.mjs +109 -0
- package/src/test-rules/file-rules.mjs +279 -0
- package/src/test-rules/rule-helpers.mjs +244 -0
- package/src/test-rules/syntax.mjs +223 -0
- package/src/test-rules/test-rules.mjs +127 -0
- package/src/testdiff.mjs +94 -32
- package/src/tests.mjs +31 -483
- package/src/text-checks.mjs +229 -69
- package/src/tokens.mjs +12 -4
- package/src/trust/scan.mjs +95 -0
- package/src/trust.mjs +214 -102
- package/src/weft.mjs +41 -9
- package/src/workspace.mjs +96 -19
- package/ts-plugin/context.js +57 -0
- package/ts-plugin/index.js +4 -124
- package/ts-plugin/mapping.js +65 -0
- package/ts-plugin/proxy.js +144 -0
package/src/tests.mjs
CHANGED
|
@@ -13,169 +13,18 @@
|
|
|
13
13
|
// in the file's leading comments covers the whole file; anywhere else it covers its own line and the next one.
|
|
14
14
|
// Without a reason it is ignored.
|
|
15
15
|
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
16
|
+
import { readFileSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
18
|
import ts from 'typescript'
|
|
19
|
-
import { local } from './alias.mjs'
|
|
20
19
|
import { diagnostic, positions } from './diag.mjs'
|
|
20
|
+
import { parse, testCall, walk } from './test-rules/syntax.mjs'
|
|
21
|
+
import { contextOf } from './test-rules/context.mjs'
|
|
22
|
+
import { runTestRules } from './test-rules/test-rules.mjs'
|
|
23
|
+
import { fileRules, renderResultsOf } from './test-rules/file-rules.mjs'
|
|
21
24
|
|
|
22
|
-
export
|
|
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
|
-
}
|
|
25
|
+
export { isSubject, matcherOf, norm, parse, resolveImport, testCall } from './test-rules/syntax.mjs'
|
|
133
26
|
|
|
134
|
-
|
|
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?$/, '')
|
|
27
|
+
export const testGlobs = ['**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']
|
|
179
28
|
|
|
180
29
|
/** Comment directives: `// weft-allow T004, T005: reason`. */
|
|
181
30
|
export function allowances(text, sf) {
|
|
@@ -183,7 +32,9 @@ export function allowances(text, sf) {
|
|
|
183
32
|
const file = new Set()
|
|
184
33
|
const lines = new Map()
|
|
185
34
|
const pos = positions(text)
|
|
186
|
-
for (const m of text.matchAll(
|
|
35
|
+
for (const m of text.matchAll(
|
|
36
|
+
/\/\/[ \t]*weft-allow[ \t]+((?:T\d{3}[ \t,]*)+)(?::[ \t]*(\S.*))?$/gm,
|
|
37
|
+
)) {
|
|
187
38
|
if (!m[2]?.trim()) continue
|
|
188
39
|
const codes = m[1].match(/T\d{3}/g)
|
|
189
40
|
if (m.index < firstCode) for (const c of codes) file.add(c)
|
|
@@ -217,356 +68,53 @@ export function checkTestFile(text, rel, options = {}) {
|
|
|
217
68
|
seen.add(key)
|
|
218
69
|
out.push(diagnostic({ code, file: rel, span, offset: [start, end], why, ...extra }))
|
|
219
70
|
}
|
|
220
|
-
const edit = (start, end, newText) => ({
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
}
|
|
71
|
+
const edit = (start, end, newText) => ({
|
|
72
|
+
file: rel,
|
|
73
|
+
span: pos.span(start, end),
|
|
74
|
+
offset: [start, end],
|
|
75
|
+
text: newText,
|
|
260
76
|
})
|
|
261
|
-
const
|
|
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 ---------------------------------------------------------------------------
|
|
77
|
+
const context = { ...contextOf(sf, abs), sf, edit, report }
|
|
280
78
|
const tests = []
|
|
281
|
-
walk(sf, n => {
|
|
79
|
+
walk(sf, (n) => {
|
|
282
80
|
if (!ts.isCallExpression(n)) return
|
|
283
81
|
const t = testCall(n)
|
|
284
82
|
if (t) tests.push(t)
|
|
285
83
|
})
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
}
|
|
84
|
+
for (const t of tests) runTestRules(t, context)
|
|
85
|
+
context.renderResults = renderResultsOf(sf, report)
|
|
86
|
+
walk(sf, (n) => {
|
|
87
|
+
for (const rule of fileRules) rule(n, context)
|
|
343
88
|
})
|
|
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
89
|
return out
|
|
418
90
|
}
|
|
419
91
|
|
|
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
92
|
/**
|
|
550
93
|
* T-codes for the project's test files.
|
|
551
94
|
* @param {string} root absolute project root
|
|
552
95
|
* @param {string[]} rels test files, relative to `root`
|
|
553
96
|
*/
|
|
554
97
|
export function checkTests(root, rels) {
|
|
555
|
-
return rels.flatMap(rel => {
|
|
98
|
+
return rels.flatMap((rel) => {
|
|
556
99
|
const abs = join(root, rel)
|
|
557
100
|
return checkTestFile(readFileSync(abs, 'utf8'), rel, { abs })
|
|
558
101
|
})
|
|
559
102
|
}
|
|
560
103
|
|
|
561
|
-
export const isTestFile = rel => /\.(test|spec)\.[cm]?[jt]sx?$/.test(rel)
|
|
104
|
+
export const isTestFile = (rel) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(rel)
|
|
562
105
|
|
|
563
106
|
/** Which T-codes this pass reports: each catalog example must produce its own code (the T side of `probe()`). */
|
|
564
107
|
export function probeTests(catalog) {
|
|
565
108
|
const out = {}
|
|
566
109
|
for (const e of catalog) {
|
|
567
110
|
if (!/^T\d{3}$/.test(e.code) || e.by !== 'cli') continue // T012 is @weft-js/core/test's runtime check
|
|
568
|
-
const hit =
|
|
569
|
-
|
|
111
|
+
const hit =
|
|
112
|
+
e.example && checkTestFile(e.example, 'probe.test.tsx').find((d) => d.code === e.code)
|
|
113
|
+
out[e.code] = {
|
|
114
|
+
implemented: Boolean(hit),
|
|
115
|
+
autofix: hit?.fix?.safe === true,
|
|
116
|
+
suggestion: Boolean(hit?.fix?.edits) && hit.fix.safe !== true,
|
|
117
|
+
}
|
|
570
118
|
}
|
|
571
119
|
return out
|
|
572
120
|
}
|