Skip to content

Commit 38cd4fd

Browse files
Support t.snapshot() formatAsCodeBlock options
Closes #361 See avajs/ava#3449 Co-authored-by: Mark Wubben <mark@novemberborn.net>
1 parent a867c89 commit 38cd4fd

2 files changed

Lines changed: 234 additions & 29 deletions

File tree

rules/assertion-arguments.js

Lines changed: 168 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import createAvaRule, {visitIf} from '../create-ava-rule.js';
22
import {
3-
getStaticValue, isOpeningParenToken, isCommaToken, findVariable,
3+
getStaticValue,
4+
isOpeningParenToken,
5+
isCommaToken,
6+
findVariable,
7+
getPropertyName,
48
} from '@eslint-community/eslint-utils';
59
import util from '../util.js';
610

@@ -13,6 +17,8 @@ const MESSAGE_ID_OUT_OF_ORDER = 'out-of-order';
1317
const MESSAGE_ID_PLAN_NOT_INTEGER = 'plan-not-integer';
1418
const MESSAGE_ID_REGEX_FIRST = 'regex-first-argument';
1519

20+
const errorNamePattern = /^(?:[A-Z][a-z\d]*)*Error$/;
21+
1622
const expectedNbArguments = {
1723
assert: {
1824
min: 1,
@@ -78,10 +84,6 @@ const expectedNbArguments = {
7884
min: 2,
7985
max: 3,
8086
},
81-
snapshot: {
82-
min: 1,
83-
max: 2,
84-
},
8587
teardown: {
8688
min: 1,
8789
max: 1,
@@ -225,6 +227,156 @@ function isString(node) {
225227
|| (type === 'BinaryExpression' && node.operator === '+' && (isString(node.left) || isString(node.right)));
226228
}
227229

230+
function resolveIdentifier(node, context) {
231+
return resolveIdentifierWithScope(node, context).node;
232+
}
233+
234+
function resolveIdentifierWithScope(node, context) {
235+
const scope = context.sourceCode.getScope(node);
236+
237+
if (node.type !== 'Identifier') {
238+
return {node, scope};
239+
}
240+
241+
const variable = findVariable(scope, node);
242+
const lastWrite = variable?.references.findLast(reference =>
243+
reference.writeExpr
244+
&& isWriteReferenceReachable(scope, reference.from)
245+
&& reference.identifier.range[1] < node.range[0]);
246+
247+
return {
248+
node: lastWrite?.writeExpr ?? node,
249+
scope: lastWrite?.from ?? scope,
250+
};
251+
}
252+
253+
function isWriteReferenceReachable(scope, referenceScope) {
254+
const ancestors = new Set();
255+
for (let current = scope; current; current = current.upper) {
256+
ancestors.add(current);
257+
}
258+
259+
for (let current = referenceScope; current; current = current.upper) {
260+
if (ancestors.has(current)) {
261+
return true;
262+
}
263+
264+
if (current.type === 'function') {
265+
return false;
266+
}
267+
}
268+
269+
return false;
270+
}
271+
272+
function getSnapshotOptionsStatus(node, context) {
273+
const resolved = resolveIdentifierWithScope(node, context);
274+
node = resolved.node;
275+
const {scope} = resolved;
276+
277+
if (node.type === 'ObjectExpression') {
278+
let hasUnknownProperty = false;
279+
280+
for (const property of node.properties) {
281+
if (property.type === 'SpreadElement') {
282+
const staticValue = getStaticValue(property.argument, scope);
283+
if (staticValue !== null && typeof staticValue.value === 'object' && staticValue.value !== null && Object.hasOwn(staticValue.value, 'formatAsCodeBlock')) {
284+
return 'yes';
285+
}
286+
287+
if (!staticValue) {
288+
hasUnknownProperty = true;
289+
}
290+
291+
continue;
292+
}
293+
294+
const propertyName = getPropertyName(property, scope);
295+
if (propertyName === 'formatAsCodeBlock') {
296+
return 'yes';
297+
}
298+
299+
if (property.computed && (propertyName === undefined || propertyName === null)) {
300+
hasUnknownProperty = true;
301+
}
302+
}
303+
304+
return hasUnknownProperty ? 'unknown' : 'no';
305+
}
306+
307+
if (node.type === 'Identifier' && errorNamePattern.test(node.name)) {
308+
return 'no';
309+
}
310+
311+
const staticValue = getStaticValue(node, scope);
312+
if (staticValue !== null) {
313+
return typeof staticValue.value === 'object' && staticValue.value !== null && Object.hasOwn(staticValue.value, 'formatAsCodeBlock') ? 'yes' : 'no';
314+
}
315+
316+
return 'unknown';
317+
}
318+
319+
function getMessageArgument(node, index, context) {
320+
const argument = node.arguments[index];
321+
const message = resolveIdentifier(argument, context);
322+
323+
if (message.type === 'Identifier') {
324+
if (!errorNamePattern.test(message.name)) {
325+
return;
326+
}
327+
} else if (message.type === 'MemberExpression' || message.type === 'ChainExpression' || message.type === 'CallExpression') {
328+
return; // Cannot statically determine the type (e.g. `error.message`, `getMessage()`)
329+
}
330+
331+
return message;
332+
}
333+
334+
function checkMessageArgument({node, index, context}) {
335+
const message = getMessageArgument(node, index, context);
336+
if (message && !isString(message)) {
337+
context.report({node, messageId: MESSAGE_ID_NOT_STRING});
338+
}
339+
}
340+
341+
function checkSnapshotArguments({
342+
node,
343+
context,
344+
enforcesMessage,
345+
shouldHaveMessage,
346+
}) {
347+
const gottenArguments = node.arguments.length;
348+
349+
if (gottenArguments < 1) {
350+
context.report({node, messageId: MESSAGE_ID_TOO_FEW, data: {min: 1}});
351+
return;
352+
}
353+
354+
const optionsStatus = node.arguments[1] ? getSnapshotOptionsStatus(node.arguments[1], context) : 'no';
355+
const hasOptions = optionsStatus === 'yes' || (optionsStatus === 'unknown' && gottenArguments > 2);
356+
const max = hasOptions ? 3 : 2;
357+
358+
if (gottenArguments > max) {
359+
context.report({node, messageId: MESSAGE_ID_TOO_MANY, data: {max}});
360+
return;
361+
}
362+
363+
const messageIndex = hasOptions ? 2 : 1;
364+
const hasMessage = gottenArguments > messageIndex;
365+
const hasAmbiguousMessage = gottenArguments === 2 && optionsStatus === 'unknown';
366+
367+
if (enforcesMessage && !hasAmbiguousMessage) {
368+
if (!hasMessage && shouldHaveMessage) {
369+
context.report({node, messageId: MESSAGE_ID_MISSING_MESSAGE});
370+
} else if (hasMessage && !shouldHaveMessage) {
371+
context.report({node, messageId: MESSAGE_ID_FOUND_MESSAGE});
372+
}
373+
}
374+
375+
if (hasMessage && !hasAmbiguousMessage) {
376+
checkMessageArgument({node, index: messageIndex, context});
377+
}
378+
}
379+
228380
const create = context => {
229381
const ava = createAvaRule(context.sourceCode);
230382
const options = context.options[0];
@@ -266,6 +418,16 @@ const create = context => {
266418
return;
267419
}
268420

421+
if (firstNonSkipMember === 'snapshot') {
422+
checkSnapshotArguments({
423+
node,
424+
context,
425+
enforcesMessage,
426+
shouldHaveMessage,
427+
});
428+
return;
429+
}
430+
269431
const nArguments = expectedNbArguments[firstNonSkipMember];
270432

271433
if (!nArguments) {
@@ -328,30 +490,7 @@ const create = context => {
328490
}
329491

330492
if (gottenArguments === nArguments.max && nArguments.min !== nArguments.max) {
331-
let lastArgument = node.arguments.at(-1);
332-
333-
if (lastArgument.type === 'Identifier') {
334-
const variable = findVariable(context.sourceCode.getScope(node), lastArgument);
335-
336-
let resolved;
337-
if (variable) {
338-
for (const reference of variable.references) {
339-
resolved = reference.writeExpr ?? resolved;
340-
}
341-
}
342-
343-
if (resolved) {
344-
lastArgument = resolved;
345-
} else if (!/^(?:[A-Z][a-z\d]*)*Error$/.test(lastArgument.name)) {
346-
return;
347-
}
348-
} else if (lastArgument.type === 'MemberExpression' || lastArgument.type === 'ChainExpression' || lastArgument.type === 'CallExpression') {
349-
return; // Cannot statically determine the type (e.g. `error.message`, `getMessage()`)
350-
}
351-
352-
if (!isString(lastArgument)) {
353-
context.report({node, messageId: MESSAGE_ID_NOT_STRING});
354-
}
493+
checkMessageArgument({node, index: node.arguments.length - 1, context});
355494
}
356495
}),
357496
});

test/assertion-arguments.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,23 @@ function testCase(message, content, errors = [], {
7373
return result;
7474
}
7575

76+
function rawTestCase(message, code, errors = []) {
77+
if (!Array.isArray(errors)) {
78+
errors = [errors];
79+
}
80+
81+
const result = {
82+
options: message ? [{message}] : [],
83+
code,
84+
};
85+
86+
if (errors.length > 0) {
87+
result.errors = errors;
88+
}
89+
90+
return result;
91+
}
92+
7693
const statics = [
7794
'null',
7895
'true',
@@ -180,6 +197,21 @@ ruleTester.run('assertion-arguments', rule, {
180197
testCase(false, 't.true(true, \'message\');'),
181198
testCase(false, 't.truthy(\'unicorn\', \'message\');'),
182199
testCase(false, 't.snapshot(value, \'message\');'),
200+
testCase(false, 't.snapshot(value, {formatAsCodeBlock: true});'),
201+
testCase(false, 't.snapshot(value, {formatAsCodeBlock: true}, \'message\');'),
202+
testCase(false, 't.snapshot(value, {\'formatAsCodeBlock\': true}, \'message\');'),
203+
testCase(false, 't.snapshot(value, {[\'formatAsCodeBlock\']: true}, \'message\');'),
204+
testCase(false, 't.snapshot(value, opts, \'message\');'),
205+
testCase(false, 'const key = getKey(); t.snapshot(value, {[key]: true}, \'message\');'),
206+
testCase(false, 't.snapshot(value, {...options}, \'message\');'),
207+
testCase(false, 'const options = {...{formatAsCodeBlock: true}}; t.snapshot(value, options, \'message\');'),
208+
testCase(false, 'const options = {formatAsCodeBlock: true}; t.snapshot(value, options, \'message\');'),
209+
testCase(false, 'const options = true ? {formatAsCodeBlock: true} : {}; t.snapshot(value, options, \'message\');'),
210+
testCase(false, 'let options = {formatAsCodeBlock: true}; t.snapshot(value, options, \'message\'); options = {};'),
211+
testCase(false, 'let options = {formatAsCodeBlock: true}; function reset() { options = {}; } t.snapshot(value, options, \'message\');'),
212+
testCase(false, 'const message = \'ok\'; t.snapshot(value, message);'),
213+
testCase(false, 'const message = \'ok\'; t.snapshot(value, {formatAsCodeBlock: true}, message);'),
214+
rawTestCase(false, 'const options = {formatAsCodeBlock: true}; test(t => { t.snapshot(value, options, \'message\'); });'),
183215
testCase(false, 't.context.plan();'),
184216
testCase(false, 't.teardown(() => {});'),
185217
testCase(false, 't.timeout(100, \'message\');'),
@@ -239,6 +271,15 @@ ruleTester.run('assertion-arguments', rule, {
239271
testCase('always', 't.ifError(new Error(), \'message\');'),
240272
testCase('always', 't.is.skip(\'same\', \'same\', \'message\');'),
241273
testCase('always', 't.snapshot(value, \'message\');'),
274+
testCase('always', 't.snapshot(value, {formatAsCodeBlock: true}, \'message\');'),
275+
testCase('always', 't.snapshot(value, {\'formatAsCodeBlock\': true}, \'message\');'),
276+
testCase('always', 't.snapshot(value, {[\'formatAsCodeBlock\']: true}, \'message\');'),
277+
testCase('always', 't.snapshot(value, opts, \'message\');'),
278+
testCase('always', 'const key = getKey(); t.snapshot(value, {[key]: true});'),
279+
testCase('always', 't.snapshot(value, {...options});'),
280+
testCase('always', 'const options = {formatAsCodeBlock: true}; t.snapshot(value, options, \'message\');'),
281+
testCase('always', 'const message = \'ok\'; t.snapshot(value, message);'),
282+
testCase('always', 'const message = \'ok\'; t.snapshot(value, {formatAsCodeBlock: true}, message);'),
242283
testCase('always', 't.teardown(() => {});'),
243284
testCase('always', 't.timeout(100, \'message\');'),
244285
testCase('always', 't.try(tt => tt.pass(\'ok\'));'),
@@ -276,6 +317,12 @@ ruleTester.run('assertion-arguments', rule, {
276317
testCase('never', 't.ifError(new Error());'),
277318
testCase('never', 't.is.skip(\'same\', \'same\');'),
278319
testCase('never', 't.snapshot(value);'),
320+
testCase('never', 't.snapshot(value, {formatAsCodeBlock: true});'),
321+
testCase('never', 't.snapshot(value, {\'formatAsCodeBlock\': true});'),
322+
testCase('never', 't.snapshot(value, opts);'),
323+
testCase('never', 'const key = getKey(); t.snapshot(value, {[key]: true});'),
324+
testCase('never', 't.snapshot(value, {...options});'),
325+
testCase('never', 'const options = {formatAsCodeBlock: true}; t.snapshot(value, options);'),
279326
testCase('never', 't.teardown(() => {});'),
280327
testCase('never', 't.timeout(100);'),
281328
testCase('never', 't.try(tt => tt.pass());'),
@@ -410,6 +457,10 @@ ruleTester.run('assertion-arguments', rule, {
410457
testCase(false, 't.ifError(new Error(), \'message\', \'extra argument\');', tooManyError()),
411458
testCase(false, 't.is.skip(\'same\', \'same\', \'message\', \'extra argument\');', tooManyError()),
412459
testCase(false, 't.snapshot(value, \'message\', \'extra argument\');', tooManyError()),
460+
testCase(false, 't.snapshot(value, TypeError, \'message\');', tooManyError()),
461+
testCase(false, 'const options = {}; t.snapshot(value, options, \'message\');', tooManyError()),
462+
testCase(false, 'const options = true ? {} : {}; t.snapshot(value, options, \'message\');', tooManyError()),
463+
testCase(false, 't.snapshot(value, {formatAsCodeBlock: true}, \'message\', \'extra argument\');', tooManyError()),
413464
testCase(false, 't.teardown(() => {}, \'extra argument\');', tooManyError()),
414465
testCase(false, 't.timeout(1, \'message\', \'extra argument\');', tooManyError()),
415466

@@ -436,6 +487,13 @@ ruleTester.run('assertion-arguments', rule, {
436487
testCase('always', 't.ifError(new Error());', missingError),
437488
testCase('always', 't.is.skip(\'same\', \'same\');', missingError),
438489
testCase('always', 't.snapshot(value);', missingError),
490+
testCase('always', 't.snapshot(value, {formatAsCodeBlock: true});', missingError),
491+
testCase('always', 'const key = \'formatAsCodeBlock\'; t.snapshot(value, {[key]: true});', missingError),
492+
testCase('always', 'const options = {formatAsCodeBlock: true}; t.snapshot(value, {...options});', missingError),
493+
testCase('always', 'const options = true ? {formatAsCodeBlock: true} : {}; t.snapshot(value, options);', missingError),
494+
rawTestCase('always', 'const options = {formatAsCodeBlock: true}; test(t => { t.snapshot(value, options); });', missingError),
495+
rawTestCase('always', 'const key = \'formatAsCodeBlock\'; const options = {[key]: true}; test(t => { const key = \'other\'; t.snapshot(value, options); });', missingError),
496+
rawTestCase('always', 'const base = {formatAsCodeBlock: true}; const options = {...base}; test(t => { const base = {}; t.snapshot(value, options); });', missingError),
439497

440498
testCase('never', 't.assert(true, \'message\');', foundError),
441499
testCase('never', 't.pass(\'message\');', foundError),
@@ -458,6 +516,8 @@ ruleTester.run('assertion-arguments', rule, {
458516
testCase('never', 't.ifError(new Error(), \'message\');', foundError),
459517
testCase('never', 't.is.skip(\'same\', \'same\', \'message\');', foundError),
460518
testCase('never', 't.snapshot(value, \'message\');', foundError),
519+
testCase('never', 't.snapshot(value, {formatAsCodeBlock: true}, \'message\');', foundError),
520+
testCase('never', 't.snapshot(value, opts, \'message\');', foundError),
461521

462522
testCase(false, 't.end(\'too many\', \'arguments\');', tooManyError()),
463523
testCase(false, 't.end.skip(\'too many\', \'arguments\');', tooManyError()),
@@ -503,6 +563,12 @@ ruleTester.run('assertion-arguments', rule, {
503563
testCase(false, 't.deepEqual({}, {}, 42);', messageIsNotStringError),
504564
testCase(false, 't.fail({});', messageIsNotStringError),
505565
testCase(false, 'let message = "ok"; message = false; t.assert(true, message);', messageIsNotStringError),
566+
testCase(false, 'let message = "ok"; { message = false; } t.assert(true, message);', messageIsNotStringError),
567+
rawTestCase(false, 'const message = true; test(t => { t.assert(true, message); });', messageIsNotStringError),
568+
testCase(false, 't.snapshot(value, true);', messageIsNotStringError),
569+
testCase(false, 't.snapshot(value, TypeError);', messageIsNotStringError),
570+
testCase(false, 't.snapshot(value, {formatAsCodeBlock: true}, true);', messageIsNotStringError),
571+
testCase(false, 'const message = true; t.snapshot(value, {formatAsCodeBlock: true}, message);', messageIsNotStringError),
506572

507573
// Error constructors are not valid assertion messages
508574
testCase(false, 't.notThrows(() => {}, TypeError);', messageIsNotStringError),

0 commit comments

Comments
 (0)