@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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, relative } from 'node:path'
|
|
3
|
+
import ts from 'typescript'
|
|
4
|
+
import { local } from '../alias.mjs'
|
|
5
|
+
import {
|
|
6
|
+
assertingFunctions,
|
|
7
|
+
contains,
|
|
8
|
+
moduleKey,
|
|
9
|
+
parse,
|
|
10
|
+
resolveImport,
|
|
11
|
+
rootName,
|
|
12
|
+
unwrap,
|
|
13
|
+
walk,
|
|
14
|
+
} from './syntax.mjs'
|
|
15
|
+
|
|
16
|
+
export function importsOf(sf, abs) {
|
|
17
|
+
const imports = new Map() // local name → { spec, name, abs }
|
|
18
|
+
const namespaces = new Map() // `import * as x` → spec
|
|
19
|
+
for (const st of sf.statements) {
|
|
20
|
+
if (!ts.isImportDeclaration(st) || !st.importClause || !ts.isStringLiteral(st.moduleSpecifier))
|
|
21
|
+
continue
|
|
22
|
+
const spec = st.moduleSpecifier.text
|
|
23
|
+
const target = resolveImport(abs, spec)
|
|
24
|
+
if (st.importClause.name)
|
|
25
|
+
imports.set(st.importClause.name.text, { spec, name: 'default', abs: target })
|
|
26
|
+
const nb = st.importClause.namedBindings
|
|
27
|
+
if (nb && ts.isNamespaceImport(nb)) namespaces.set(nb.name.text, { spec, abs: target })
|
|
28
|
+
if (nb && ts.isNamedImports(nb))
|
|
29
|
+
for (const el of nb.elements)
|
|
30
|
+
imports.set(el.name.text, { spec, name: (el.propertyName ?? el.name).text, abs: target })
|
|
31
|
+
}
|
|
32
|
+
const helperAsserting = new Set()
|
|
33
|
+
const helperCache = new Map()
|
|
34
|
+
for (const [binding, imp] of imports) {
|
|
35
|
+
if (!imp.abs || /\.(test|spec)\./.test(imp.abs)) continue
|
|
36
|
+
if (!helperCache.has(imp.abs)) {
|
|
37
|
+
let names = new Set()
|
|
38
|
+
try {
|
|
39
|
+
const src = readFileSync(imp.abs, 'utf8')
|
|
40
|
+
names = assertingFunctions(parse(src, imp.abs))
|
|
41
|
+
} catch {}
|
|
42
|
+
helperCache.set(imp.abs, names)
|
|
43
|
+
}
|
|
44
|
+
if (helperCache.get(imp.abs).has(imp.name)) helperAsserting.add(binding)
|
|
45
|
+
}
|
|
46
|
+
return { imports, namespaces, helperAsserting }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function unitsOf(sf, abs, imports) {
|
|
50
|
+
const renders = new Set()
|
|
51
|
+
walk(sf, (n) => {
|
|
52
|
+
if (
|
|
53
|
+
ts.isCallExpression(n) &&
|
|
54
|
+
ts.isIdentifier(n.expression) &&
|
|
55
|
+
n.expression.text === 'render' &&
|
|
56
|
+
n.arguments[0]
|
|
57
|
+
) {
|
|
58
|
+
const first = unwrap(n.arguments[0])
|
|
59
|
+
if (ts.isIdentifier(first)) renders.add(first.text)
|
|
60
|
+
walk(first, (j) => {
|
|
61
|
+
if (
|
|
62
|
+
(ts.isJsxOpeningElement(j) || ts.isJsxSelfClosingElement(j)) &&
|
|
63
|
+
ts.isIdentifier(j.tagName)
|
|
64
|
+
)
|
|
65
|
+
renders.add(j.tagName.text)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
const isComponentTest = contains(
|
|
70
|
+
sf,
|
|
71
|
+
(n) =>
|
|
72
|
+
ts.isCallExpression(n) &&
|
|
73
|
+
ts.isIdentifier(n.expression) &&
|
|
74
|
+
['render', 'mount', 'hydrate'].includes(n.expression.text),
|
|
75
|
+
)
|
|
76
|
+
const units = new Set()
|
|
77
|
+
const unitSpecs = new Set()
|
|
78
|
+
const specKey = (spec) => spec.replace(/(\/index)?\.[cm]?[jt]sx?$/, '')
|
|
79
|
+
for (const name of renders) {
|
|
80
|
+
const imp = imports.get(name)
|
|
81
|
+
if (imp?.abs) units.add(moduleKey(imp.abs))
|
|
82
|
+
if (imp && local(abs, imp.spec)) unitSpecs.add(specKey(imp.spec))
|
|
83
|
+
}
|
|
84
|
+
const own = resolveImport(
|
|
85
|
+
abs,
|
|
86
|
+
`./${relative(dirname(abs), abs).replace(/\.(test|spec)\.[cm]?[jt]sx?$/, '')}`,
|
|
87
|
+
)
|
|
88
|
+
if (own) units.add(moduleKey(own))
|
|
89
|
+
const isUnitSpec = (spec) => {
|
|
90
|
+
const t = resolveImport(abs, spec)
|
|
91
|
+
return t ? units.has(moduleKey(t)) : unitSpecs.has(specKey(spec))
|
|
92
|
+
}
|
|
93
|
+
const isWeftSpec = (spec) =>
|
|
94
|
+
spec === '@weft-js/core' || spec.startsWith('@weft-js/core/') || spec.startsWith('@weft-js/')
|
|
95
|
+
return { isComponentTest, isUnitSpec, isWeftSpec }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function contextOf(sf, abs) {
|
|
99
|
+
const { imports, namespaces, helperAsserting } = importsOf(sf, abs)
|
|
100
|
+
const asserting = assertingFunctions(sf, helperAsserting)
|
|
101
|
+
const { isComponentTest, isUnitSpec, isWeftSpec } = unitsOf(sf, abs, imports)
|
|
102
|
+
const fakeTimers = contains(
|
|
103
|
+
sf,
|
|
104
|
+
(n) =>
|
|
105
|
+
ts.isPropertyAccessExpression(n) &&
|
|
106
|
+
(n.name.text === 'useFakeTimers' || (n.name.text === 'install' && rootName(n) === 'clock')),
|
|
107
|
+
)
|
|
108
|
+
return { imports, namespaces, asserting, isComponentTest, isUnitSpec, isWeftSpec, fakeTimers }
|
|
109
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
import {
|
|
3
|
+
contains,
|
|
4
|
+
hasCall,
|
|
5
|
+
isLiteral,
|
|
6
|
+
isSubject,
|
|
7
|
+
matcherOf,
|
|
8
|
+
norm,
|
|
9
|
+
rootName,
|
|
10
|
+
unwrap,
|
|
11
|
+
walk,
|
|
12
|
+
} from './syntax.mjs'
|
|
13
|
+
import {
|
|
14
|
+
flushFix,
|
|
15
|
+
insideFakeApi,
|
|
16
|
+
isComparison,
|
|
17
|
+
readsInternalState,
|
|
18
|
+
sameValueMatchers,
|
|
19
|
+
sleepPromise,
|
|
20
|
+
} from './rule-helpers.mjs'
|
|
21
|
+
|
|
22
|
+
const snapshotMatchers = new Set([
|
|
23
|
+
'toMatchSnapshot',
|
|
24
|
+
'toMatchInlineSnapshot',
|
|
25
|
+
'toMatchFileSnapshot',
|
|
26
|
+
'toThrowErrorMatchingSnapshot',
|
|
27
|
+
'toThrowErrorMatchingInlineSnapshot',
|
|
28
|
+
])
|
|
29
|
+
const domQueries = new Set([
|
|
30
|
+
'querySelector',
|
|
31
|
+
'querySelectorAll',
|
|
32
|
+
'getElementsByClassName',
|
|
33
|
+
'getElementsByTagName',
|
|
34
|
+
'getElementById',
|
|
35
|
+
'closest',
|
|
36
|
+
])
|
|
37
|
+
const timerCalls = new Set(['setTimeout', 'setInterval', 'requestAnimationFrame', 'setImmediate'])
|
|
38
|
+
|
|
39
|
+
export function renderResultsOf(sf, report) {
|
|
40
|
+
const renderResults = new Set()
|
|
41
|
+
walk(sf, (n) => {
|
|
42
|
+
if (
|
|
43
|
+
ts.isVariableDeclaration(n) &&
|
|
44
|
+
n.initializer &&
|
|
45
|
+
ts.isCallExpression(unwrap(n.initializer)) &&
|
|
46
|
+
rootName(unwrap(n.initializer).expression) === 'render'
|
|
47
|
+
) {
|
|
48
|
+
if (ts.isIdentifier(n.name)) renderResults.add(n.name.text)
|
|
49
|
+
if (ts.isObjectBindingPattern(n.name))
|
|
50
|
+
for (const el of n.name.elements)
|
|
51
|
+
if ((el.propertyName ?? el.name).getText() === 'container')
|
|
52
|
+
report('T005', el, '`container` reaches into the DOM tree the user never sees.')
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
return renderResults
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function subjectRule(n, { report }) {
|
|
59
|
+
if (isSubject(n)) {
|
|
60
|
+
const m = matcherOf(n)
|
|
61
|
+
const subject = n.arguments[0]
|
|
62
|
+
if (m && subject) {
|
|
63
|
+
if (isLiteral(subject))
|
|
64
|
+
report('T002', n, `\`${norm(n)}\` checks a literal, so it can never fail.`)
|
|
65
|
+
else if (
|
|
66
|
+
sameValueMatchers.has(m.name) &&
|
|
67
|
+
!m.not &&
|
|
68
|
+
m.call.arguments[0] &&
|
|
69
|
+
norm(m.call.arguments[0]) === norm(subject) &&
|
|
70
|
+
!hasCall(subject)
|
|
71
|
+
)
|
|
72
|
+
report(
|
|
73
|
+
'T002',
|
|
74
|
+
m.call,
|
|
75
|
+
`\`${norm(m.call)}\` compares \`${norm(subject)}\` with itself, so it can never fail.`,
|
|
76
|
+
)
|
|
77
|
+
else if (
|
|
78
|
+
ts.isBinaryExpression(unwrap(subject)) &&
|
|
79
|
+
isComparison(unwrap(subject)) &&
|
|
80
|
+
norm(unwrap(subject).left) === norm(unwrap(subject).right) &&
|
|
81
|
+
!hasCall(subject)
|
|
82
|
+
)
|
|
83
|
+
report(
|
|
84
|
+
'T002',
|
|
85
|
+
n,
|
|
86
|
+
`\`${norm(subject)}\` compares a value with itself, so it can never fail.`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function snapshotRule(n, { report }) {
|
|
93
|
+
if (!isSubject(n)) return
|
|
94
|
+
const m = matcherOf(n)
|
|
95
|
+
const subject = n.arguments[0]
|
|
96
|
+
if (!m || !subject) return
|
|
97
|
+
if (snapshotMatchers.has(m.name))
|
|
98
|
+
report(
|
|
99
|
+
'T007',
|
|
100
|
+
m.node,
|
|
101
|
+
`\`${m.name}\` records whatever the code does today and passes against it: it pins output, not behavior.`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function subjectDomRule(n, { report, isComponentTest, imports }) {
|
|
106
|
+
if (!isSubject(n)) return
|
|
107
|
+
const m = matcherOf(n)
|
|
108
|
+
const subject = n.arguments[0]
|
|
109
|
+
if (!m || !subject) return
|
|
110
|
+
if (m.name === 'toHaveClass')
|
|
111
|
+
report('T005', m.node, '`toHaveClass` asserts on styling classes, not on what the user sees.')
|
|
112
|
+
if (
|
|
113
|
+
contains(
|
|
114
|
+
subject,
|
|
115
|
+
(x) =>
|
|
116
|
+
ts.isPropertyAccessExpression(x) &&
|
|
117
|
+
['className', 'classList', 'innerHTML', 'outerHTML'].includes(x.name.text),
|
|
118
|
+
)
|
|
119
|
+
) {
|
|
120
|
+
const prop = []
|
|
121
|
+
walk(subject, (x) => {
|
|
122
|
+
if (
|
|
123
|
+
ts.isPropertyAccessExpression(x) &&
|
|
124
|
+
['className', 'classList', 'innerHTML', 'outerHTML'].includes(x.name.text)
|
|
125
|
+
)
|
|
126
|
+
prop.push(x.name.text)
|
|
127
|
+
})
|
|
128
|
+
report(
|
|
129
|
+
'T005',
|
|
130
|
+
subject,
|
|
131
|
+
`Asserting on \`${prop[0]}\` pins markup and classes, not what the user sees.`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
if (isComponentTest && readsInternalState(subject, imports))
|
|
135
|
+
report(
|
|
136
|
+
'T005',
|
|
137
|
+
subject,
|
|
138
|
+
`\`${norm(subject)}\` reads the component's state directly instead of what it renders.`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function queryRule(n, { report }, c, name) {
|
|
143
|
+
if (name && domQueries.has(name) && ts.isPropertyAccessExpression(c))
|
|
144
|
+
report('T005', c.name, `\`${name}\` queries by CSS structure, which users never see.`)
|
|
145
|
+
if (name && /^(get|query|find)(All)?ByTestId$/.test(name))
|
|
146
|
+
report(
|
|
147
|
+
'T005',
|
|
148
|
+
ts.isPropertyAccessExpression(c) ? c.name : c,
|
|
149
|
+
`\`${name}\` finds the element by a test id the user never sees.`,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function timerRule(n, { sf, edit, report, fakeTimers }, c, name) {
|
|
154
|
+
if (name === 'waitFor' || name === 'waitForElementToBeRemoved')
|
|
155
|
+
report('T004', c, `\`${name}\` polls on real timers and guesses how long the update takes.`, {
|
|
156
|
+
help: '`await user.click(…)` and `await flush()` settle every update; there is nothing to wait for',
|
|
157
|
+
})
|
|
158
|
+
if (ts.isIdentifier(c) && ['sleep', 'delay'].includes(c.text) && ts.isAwaitExpression(n.parent))
|
|
159
|
+
report(
|
|
160
|
+
'T004',
|
|
161
|
+
n,
|
|
162
|
+
`\`${norm(n)}\` sleeps on the real clock: slow, and flaky when the machine is busy.`,
|
|
163
|
+
)
|
|
164
|
+
if (
|
|
165
|
+
name &&
|
|
166
|
+
timerCalls.has(name) &&
|
|
167
|
+
(ts.isIdentifier(c) || ['window', 'globalThis', 'global'].includes(rootName(c)))
|
|
168
|
+
) {
|
|
169
|
+
const sleep = sleepPromise(n)
|
|
170
|
+
if (sleep) {
|
|
171
|
+
const delay = n.arguments[1]
|
|
172
|
+
const zero = !delay || (ts.isNumericLiteral(delay) && Number(delay.text) === 0)
|
|
173
|
+
const fix = zero ? flushFix(sf, sleep, edit) : undefined
|
|
174
|
+
report(
|
|
175
|
+
'T004',
|
|
176
|
+
sleep,
|
|
177
|
+
`\`${norm(sleep)}\` sleeps on the real clock: slow, and flaky when the machine is busy.`,
|
|
178
|
+
{
|
|
179
|
+
help: zero
|
|
180
|
+
? 'Use `await flush()`: it settles every pending update'
|
|
181
|
+
: `Install the fake clock (\`clock.install()\`) and use \`await clock.advance(${delay.getText()})\``,
|
|
182
|
+
fix,
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
} else if (!fakeTimers && !insideFakeApi(n))
|
|
186
|
+
report('T004', c, `\`${name}\` runs on the real clock in a test.`, {
|
|
187
|
+
help: 'Call `clock.install()` from @weft-js/core/test first and drive time with `await clock.advance(ms)`',
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function mockRule(n, { report, namespaces, isWeftSpec, isUnitSpec }, c, name) {
|
|
193
|
+
if (
|
|
194
|
+
name &&
|
|
195
|
+
['mock', 'doMock'].includes(name) &&
|
|
196
|
+
['vi', 'jest'].includes(rootName(c)) &&
|
|
197
|
+
n.arguments[0] &&
|
|
198
|
+
ts.isStringLiteralLike(n.arguments[0])
|
|
199
|
+
) {
|
|
200
|
+
const spec = n.arguments[0].text
|
|
201
|
+
if (isWeftSpec(spec))
|
|
202
|
+
report(
|
|
203
|
+
'T006',
|
|
204
|
+
n,
|
|
205
|
+
`Mocking \`${spec}\` replaces the framework the test is supposed to exercise.`,
|
|
206
|
+
)
|
|
207
|
+
else if (isUnitSpec(spec))
|
|
208
|
+
report(
|
|
209
|
+
'T006',
|
|
210
|
+
n,
|
|
211
|
+
`Mocking \`${spec}\` replaces the unit under test, so the test checks the mock.`,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
if (
|
|
215
|
+
name === 'spyOn' &&
|
|
216
|
+
['vi', 'jest'].includes(rootName(c)) &&
|
|
217
|
+
n.arguments[0] &&
|
|
218
|
+
ts.isIdentifier(n.arguments[0])
|
|
219
|
+
) {
|
|
220
|
+
const ns = namespaces.get(n.arguments[0].text)
|
|
221
|
+
if (ns && isWeftSpec(ns.spec))
|
|
222
|
+
report(
|
|
223
|
+
'T006',
|
|
224
|
+
n,
|
|
225
|
+
`Spying on \`${ns.spec}\` replaces the framework the test is supposed to exercise.`,
|
|
226
|
+
)
|
|
227
|
+
else if (ns && isUnitSpec(ns.spec))
|
|
228
|
+
report(
|
|
229
|
+
'T006',
|
|
230
|
+
n,
|
|
231
|
+
`Spying on \`${ns.spec}\` replaces the unit under test, so the test checks the mock.`,
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function domRule(n, ctx, c, name) {
|
|
237
|
+
subjectDomRule(n, ctx)
|
|
238
|
+
queryRule(n, ctx, c, name)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const callRules = [subjectRule, snapshotRule, domRule, timerRule, mockRule]
|
|
242
|
+
|
|
243
|
+
function callRule(n, ctx) {
|
|
244
|
+
if (!ts.isCallExpression(n)) return
|
|
245
|
+
const c = n.expression
|
|
246
|
+
const name = ts.isIdentifier(c)
|
|
247
|
+
? c.text
|
|
248
|
+
: ts.isPropertyAccessExpression(c)
|
|
249
|
+
? c.name.text
|
|
250
|
+
: undefined
|
|
251
|
+
for (const rule of callRules) rule(n, ctx, c, name)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function socketRule(n, { report }) {
|
|
255
|
+
if (
|
|
256
|
+
ts.isNewExpression(n) &&
|
|
257
|
+
ts.isIdentifier(n.expression) &&
|
|
258
|
+
['WebSocket', 'EventSource'].includes(n.expression.text)
|
|
259
|
+
)
|
|
260
|
+
report('T013', n, `\`new ${n.expression.text}\` opens a real socket in a fast test.`, {
|
|
261
|
+
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()`",
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function containerRule(n, { report, renderResults }) {
|
|
266
|
+
if (
|
|
267
|
+
ts.isPropertyAccessExpression(n) &&
|
|
268
|
+
n.name.text === 'container' &&
|
|
269
|
+
!(ts.isCallExpression(n.parent) && n.parent.expression === n)
|
|
270
|
+
) {
|
|
271
|
+
const obj = unwrap(n.expression)
|
|
272
|
+
const fromRender =
|
|
273
|
+
(ts.isCallExpression(obj) && rootName(obj.expression) === 'render') ||
|
|
274
|
+
(ts.isIdentifier(obj) && renderResults.has(obj.text))
|
|
275
|
+
if (fromRender) report('T005', n, '`container` reaches into the DOM tree the user never sees.')
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export const fileRules = [callRule, socketRule, containerRule]
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
import {
|
|
3
|
+
contains,
|
|
4
|
+
hasCall,
|
|
5
|
+
isFn,
|
|
6
|
+
isLiteral,
|
|
7
|
+
isSubject,
|
|
8
|
+
matcherOf,
|
|
9
|
+
norm,
|
|
10
|
+
unwrap,
|
|
11
|
+
} from './syntax.mjs'
|
|
12
|
+
|
|
13
|
+
export const sameValueMatchers = new Set(['toBe', 'toEqual', 'toStrictEqual'])
|
|
14
|
+
const loopCallbacks = new Set([
|
|
15
|
+
'forEach',
|
|
16
|
+
'map',
|
|
17
|
+
'flatMap',
|
|
18
|
+
'filter',
|
|
19
|
+
'some',
|
|
20
|
+
'every',
|
|
21
|
+
'find',
|
|
22
|
+
'findIndex',
|
|
23
|
+
'reduce',
|
|
24
|
+
])
|
|
25
|
+
export const isComparison = (b) =>
|
|
26
|
+
[
|
|
27
|
+
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
|
28
|
+
ts.SyntaxKind.EqualsEqualsToken,
|
|
29
|
+
ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
|
30
|
+
ts.SyntaxKind.ExclamationEqualsToken,
|
|
31
|
+
].includes(b.operatorToken.kind)
|
|
32
|
+
|
|
33
|
+
/** Why `node`, under ancestor `a`, only runs sometimes (`loop`: maybe zero times); undefined when it always runs. */
|
|
34
|
+
export function conditional(a, node) {
|
|
35
|
+
const within = (x) => x && node.getStart() >= x.getStart() && node.getEnd() <= x.getEnd()
|
|
36
|
+
const when = (text) => ({ text })
|
|
37
|
+
const loop = (text) => ({ text, loop: true })
|
|
38
|
+
if (ts.isIfStatement(a) && !within(a.expression)) return when('when the `if` matches')
|
|
39
|
+
if (ts.isConditionalExpression(a) && !within(a.condition)) return when('on one side of a `?:`')
|
|
40
|
+
if (
|
|
41
|
+
ts.isBinaryExpression(a) &&
|
|
42
|
+
[
|
|
43
|
+
ts.SyntaxKind.AmpersandAmpersandToken,
|
|
44
|
+
ts.SyntaxKind.BarBarToken,
|
|
45
|
+
ts.SyntaxKind.QuestionQuestionToken,
|
|
46
|
+
].includes(a.operatorToken.kind) &&
|
|
47
|
+
within(a.right)
|
|
48
|
+
)
|
|
49
|
+
return when(`after a \`${a.operatorToken.getText()}\``)
|
|
50
|
+
if (ts.isCaseClause(a) || ts.isDefaultClause(a)) return when('in one `switch` case')
|
|
51
|
+
if (ts.isCatchClause(a)) return when('when something throws')
|
|
52
|
+
if (
|
|
53
|
+
(ts.isForStatement(a) ||
|
|
54
|
+
ts.isForOfStatement(a) ||
|
|
55
|
+
ts.isForInStatement(a) ||
|
|
56
|
+
ts.isWhileStatement(a) ||
|
|
57
|
+
ts.isDoStatement(a)) &&
|
|
58
|
+
within(a.statement)
|
|
59
|
+
)
|
|
60
|
+
return loop('inside a loop, and zero iterations check nothing')
|
|
61
|
+
if (
|
|
62
|
+
isFn(a) &&
|
|
63
|
+
ts.isCallExpression(a.parent) &&
|
|
64
|
+
ts.isPropertyAccessExpression(a.parent.expression) &&
|
|
65
|
+
loopCallbacks.has(a.parent.expression.name.text) &&
|
|
66
|
+
a.parent.arguments.includes(a)
|
|
67
|
+
)
|
|
68
|
+
return loop(`inside \`.${a.parent.expression.name.text}\`, and an empty list checks nothing`)
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `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.) */
|
|
73
|
+
export function readsInternalState(subject, imports) {
|
|
74
|
+
const s = unwrap(subject)
|
|
75
|
+
if (!ts.isPropertyAccessExpression(s) || s.name.text !== 'value') return false
|
|
76
|
+
let n = s.expression
|
|
77
|
+
for (;;) {
|
|
78
|
+
n = unwrap(n)
|
|
79
|
+
if (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) n = n.expression
|
|
80
|
+
else break
|
|
81
|
+
}
|
|
82
|
+
return ts.isIdentifier(n) && Boolean(imports.get(n.text)?.abs)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `new Promise(r => setTimeout(r, ms))`: the timer call's enclosing sleep, or undefined. */
|
|
86
|
+
export function sleepPromise(timer) {
|
|
87
|
+
const cb = timer.arguments[0]
|
|
88
|
+
let exec = timer.parent
|
|
89
|
+
while (exec && (ts.isExpressionStatement(exec) || ts.isBlock(exec))) exec = exec.parent
|
|
90
|
+
if (
|
|
91
|
+
!isFn(exec) ||
|
|
92
|
+
!ts.isNewExpression(exec.parent) ||
|
|
93
|
+
exec.parent.expression.getText() !== 'Promise'
|
|
94
|
+
)
|
|
95
|
+
return undefined
|
|
96
|
+
const resolveName = exec.parameters[0]?.name.getText()
|
|
97
|
+
const callsResolve =
|
|
98
|
+
cb &&
|
|
99
|
+
(cb.getText() === resolveName ||
|
|
100
|
+
(isFn(cb) &&
|
|
101
|
+
contains(cb.body, (x) => ts.isCallExpression(x) && x.expression.getText() === resolveName)))
|
|
102
|
+
if (!callsResolve) return undefined
|
|
103
|
+
// A fake API's latency resolves with data (`ok(user)`); a sleep resolves with nothing.
|
|
104
|
+
if (
|
|
105
|
+
isFn(cb) &&
|
|
106
|
+
contains(
|
|
107
|
+
cb.body,
|
|
108
|
+
(x) =>
|
|
109
|
+
ts.isCallExpression(x) && x.expression.getText() === resolveName && x.arguments.length > 0,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
return undefined
|
|
113
|
+
return exec.parent
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Timers inside a function that a fake API returns (`load: id => new Promise(ok => setTimeout(() => ok(x), ms))`). */
|
|
117
|
+
export function insideFakeApi(timer) {
|
|
118
|
+
for (let n = timer.parent; n; n = n.parent) {
|
|
119
|
+
if (ts.isNewExpression(n) && n.expression.getText() === 'Promise') return true
|
|
120
|
+
}
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Replace a zero-delay sleep with `flush()`, importing it from @weft-js/core/test when the file doesn't. */
|
|
125
|
+
export function flushFix(sf, sleep, edit) {
|
|
126
|
+
const edits = [edit(sleep.getStart(sf), sleep.getEnd(), 'flush()')]
|
|
127
|
+
const hasFlush = sf.statements.some(
|
|
128
|
+
(st) =>
|
|
129
|
+
ts.isImportDeclaration(st) &&
|
|
130
|
+
st.importClause?.namedBindings &&
|
|
131
|
+
ts.isNamedImports(st.importClause.namedBindings) &&
|
|
132
|
+
st.importClause.namedBindings.elements.some((e) => e.name.text === 'flush'),
|
|
133
|
+
)
|
|
134
|
+
if (!hasFlush) {
|
|
135
|
+
const weftTest = sf.statements.find(
|
|
136
|
+
(st) =>
|
|
137
|
+
ts.isImportDeclaration(st) &&
|
|
138
|
+
ts.isStringLiteral(st.moduleSpecifier) &&
|
|
139
|
+
st.moduleSpecifier.text === '@weft-js/core/test' &&
|
|
140
|
+
st.importClause?.namedBindings &&
|
|
141
|
+
ts.isNamedImports(st.importClause.namedBindings),
|
|
142
|
+
)
|
|
143
|
+
if (weftTest) {
|
|
144
|
+
const el = weftTest.importClause.namedBindings.elements
|
|
145
|
+
edits.push(edit(el[el.length - 1].getEnd(), el[el.length - 1].getEnd(), ', flush'))
|
|
146
|
+
} else {
|
|
147
|
+
const last = [...sf.statements].reverse().find((st) => ts.isImportDeclaration(st))
|
|
148
|
+
const at = last ? last.getEnd() : 0
|
|
149
|
+
edits.push(
|
|
150
|
+
edit(
|
|
151
|
+
at,
|
|
152
|
+
at,
|
|
153
|
+
`${last ? '\n' : ''}import { flush } from '@weft-js/core/test'${last ? '' : '\n'}`,
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { description: 'Replace the sleep with `flush()`', safe: false, edits }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* T003 in one test body: `n.value = 5; expect(n.value).toBe(5)`, or `const s = state(3); expect(s.value).toBe(3)`.
|
|
163
|
+
* Any call between the write and the assertion (a click, a render, a fake push) may route through the app, so it ends
|
|
164
|
+
* the check; `flush()` doesn't.
|
|
165
|
+
*/
|
|
166
|
+
export function assertsWhatItSet(statements, report) {
|
|
167
|
+
const writes = new Map()
|
|
168
|
+
const boxes = new Set() // `const n = state(…)`: a plain box, so a write reads straight back
|
|
169
|
+
const locals = new Set()
|
|
170
|
+
const isFlush = (st) => {
|
|
171
|
+
const e = ts.isExpressionStatement(st) ? unwrap(st.expression) : null
|
|
172
|
+
return e && ts.isCallExpression(e) && e.expression.getText() === 'flush'
|
|
173
|
+
}
|
|
174
|
+
for (const st of statements) {
|
|
175
|
+
if (ts.isVariableStatement(st)) {
|
|
176
|
+
for (const d of st.declarationList.declarations) {
|
|
177
|
+
if (!ts.isIdentifier(d.name) || !d.initializer) continue
|
|
178
|
+
const init = unwrap(d.initializer)
|
|
179
|
+
locals.add(d.name.text)
|
|
180
|
+
if (ts.isCallExpression(init) && init.expression.getText() === 'state')
|
|
181
|
+
boxes.add(d.name.text)
|
|
182
|
+
if (isLiteral(init)) writes.set(d.name.text, init)
|
|
183
|
+
else if (
|
|
184
|
+
ts.isCallExpression(init) &&
|
|
185
|
+
init.expression.getText() === 'state' &&
|
|
186
|
+
init.arguments[0] &&
|
|
187
|
+
isLiteral(init.arguments[0])
|
|
188
|
+
)
|
|
189
|
+
writes.set(`${d.name.text}.value`, init.arguments[0])
|
|
190
|
+
else if (hasCall(init)) writes.clear()
|
|
191
|
+
}
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
if (isFlush(st)) continue
|
|
195
|
+
const e = ts.isExpressionStatement(st) ? unwrap(st.expression) : null
|
|
196
|
+
if (
|
|
197
|
+
e &&
|
|
198
|
+
ts.isBinaryExpression(e) &&
|
|
199
|
+
e.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
200
|
+
!hasCall(e.left)
|
|
201
|
+
) {
|
|
202
|
+
const target = norm(e.left)
|
|
203
|
+
const plain =
|
|
204
|
+
(ts.isIdentifier(e.left) && locals.has(target)) ||
|
|
205
|
+
(ts.isPropertyAccessExpression(e.left) &&
|
|
206
|
+
e.left.name.text === 'value' &&
|
|
207
|
+
boxes.has(norm(e.left.expression)))
|
|
208
|
+
if (isLiteral(e.right) && plain) writes.set(target, e.right)
|
|
209
|
+
else writes.delete(target)
|
|
210
|
+
if (hasCall(e.right)) writes.clear()
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
const subject = e && findSubject(e)
|
|
214
|
+
if (subject) {
|
|
215
|
+
const m = matcherOf(subject)
|
|
216
|
+
const written = subject.arguments[0] && writes.get(norm(subject.arguments[0]))
|
|
217
|
+
if (
|
|
218
|
+
m &&
|
|
219
|
+
!m.not &&
|
|
220
|
+
sameValueMatchers.has(m.name) &&
|
|
221
|
+
written &&
|
|
222
|
+
m.call.arguments[0] &&
|
|
223
|
+
norm(m.call.arguments[0]) === norm(written)
|
|
224
|
+
)
|
|
225
|
+
report(
|
|
226
|
+
'T003',
|
|
227
|
+
m.call,
|
|
228
|
+
`The test set \`${norm(subject.arguments[0])}\` to \`${norm(written)}\` itself and asserts it back: nothing between went through the UI or the graph.`,
|
|
229
|
+
)
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
writes.clear()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The subject call of a statement that is one assertion chain (`expect(a).toBe(b)`), else undefined. */
|
|
237
|
+
function findSubject(e) {
|
|
238
|
+
let n = unwrap(e)
|
|
239
|
+
for (;;) {
|
|
240
|
+
if (ts.isCallExpression(n) && isSubject(n)) return n
|
|
241
|
+
if (ts.isCallExpression(n) || ts.isPropertyAccessExpression(n)) n = unwrap(n.expression)
|
|
242
|
+
else return undefined
|
|
243
|
+
}
|
|
244
|
+
}
|