eslint-plugin-test-governance 0.1.0 → 0.2.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.
@@ -1,6 +1,13 @@
1
+ import { DEFAULT_COMMENT_KEYWORDS } from './constants.js';
2
+ import { ConfigError } from './errors.js';
3
+ import { optionsSchema } from './schema.js';
1
4
  import { compileTicketSpec, DEFAULT_PLACEHOLDERS } from './tickets.js';
5
+ import { validate } from './validate.js';
2
6
  export const SETTINGS_KEY = 'test-governance';
3
- export const BUILTIN_STATES = {
7
+ export function isTagState(state) {
8
+ return state.tag !== undefined;
9
+ }
10
+ const BUILTIN_STATES = {
4
11
  skip: { marker: 'SKIP', modifier: 'skip', enabled: true },
5
12
  fixme: { marker: 'FIXME', modifier: 'fixme', enabled: true },
6
13
  fail: { marker: 'FAIL', modifier: 'fail', enabled: false },
@@ -8,39 +15,30 @@ export const BUILTIN_STATES = {
8
15
  new: { marker: 'NEW', tag: '@new', lifecycle: true, enabled: false },
9
16
  unstable: { marker: 'UNSTABLE', tag: '@unstable', lifecycle: true, enabled: false },
10
17
  };
11
- export const MARKER_RE = /^[A-Z][A-Z0-9_-]*$/;
12
- export const TAG_RE = /^@[\w-]+$/;
13
- export class ConfigError extends Error {
14
- constructor(message) {
15
- super(`eslint-plugin-test-governance: ${message}`);
16
- this.name = 'ConfigError';
17
- }
18
- }
18
+ export { ConfigError } from './errors.js';
19
19
  function toSpecs(spec) {
20
20
  return Array.isArray(spec) ? spec : [spec];
21
21
  }
22
22
  const cache = new WeakMap();
23
23
  const EMPTY = {};
24
24
  /**
25
- * Merges rule options over `settings['test-governance']` and compiles the result.
26
- * The result is cached per settings object, so all four rules share one compile.
25
+ * Compiles `settings['test-governance']`. Every rule reads the same settings, so they always agree,
26
+ * and the result is cached per settings object, so the rules share one compile.
27
27
  */
28
- export function resolveOptions(settings, ruleOptions) {
28
+ export function resolveOptions(settings) {
29
29
  const shared = (settings?.[SETTINGS_KEY] ?? EMPTY);
30
- const key = JSON.stringify(ruleOptions ?? null);
31
- let byRule = cache.get(shared);
32
- if (!byRule) {
33
- byRule = new Map();
34
- cache.set(shared, byRule);
35
- }
36
- let resolved = byRule.get(key);
30
+ let resolved = cache.get(shared);
37
31
  if (!resolved) {
38
- resolved = compileOptions({ ...shared, ...ruleOptions });
39
- byRule.set(key, resolved);
32
+ resolved = compileOptions(shared);
33
+ cache.set(shared, resolved);
40
34
  }
41
35
  return resolved;
42
36
  }
43
37
  export function compileOptions(options) {
38
+ const problems = validate(options, optionsSchema);
39
+ if (problems.length > 0) {
40
+ throw new ConfigError(`invalid options:\n${problems.map((problem) => ` - ${problem}`).join('\n')}`);
41
+ }
44
42
  const placeholders = options.placeholders ?? DEFAULT_PLACEHOLDERS;
45
43
  const defaultTicket = toSpecs(options.ticket ?? { preset: 'any' });
46
44
  const compile = (spec) => compileTicketSpec(spec === undefined ? defaultTicket : toSpecs(spec), placeholders);
@@ -53,21 +51,17 @@ export function compileOptions(options) {
53
51
  const enabled = override.enabled ?? (def.lifecycle ? (options.lifecycleTags ?? def.enabled) : def.enabled);
54
52
  if (!enabled)
55
53
  continue;
56
- states.push({
54
+ const base = {
57
55
  name,
58
- marker: checkMarker(override.marker ?? def.marker, name),
59
- modifier: def.modifier,
60
- tag: def.tag,
56
+ marker: override.marker ?? def.marker,
61
57
  ticket: override.ticket === undefined ? defaultMatcher : compile(override.ticket),
62
- });
58
+ };
59
+ states.push(def.modifier === undefined ? { ...base, tag: def.tag } : { ...base, modifier: def.modifier });
63
60
  }
64
61
  for (const [name, custom] of Object.entries(options.customStates ?? {})) {
65
- if (!TAG_RE.test(custom.when)) {
66
- throw new ConfigError(`customStates.${name}.when must be a tag like "@${name}", got "${custom.when}".`);
67
- }
68
62
  states.push({
69
63
  name,
70
- marker: checkMarker(custom.marker, name),
64
+ marker: custom.marker,
71
65
  tag: custom.when,
72
66
  ticket: custom.ticket === undefined ? defaultMatcher : compile(custom.ticket),
73
67
  });
@@ -84,6 +78,7 @@ export function compileOptions(options) {
84
78
  tags.add(state.tag);
85
79
  }
86
80
  }
81
+ const commentKeywords = options.comments === false ? [] : (options.comments?.keywords ?? DEFAULT_COMMENT_KEYWORDS);
87
82
  return {
88
83
  testFunctions: new Set(options.testFunctions ?? ['test']),
89
84
  states,
@@ -91,12 +86,9 @@ export function compileOptions(options) {
91
86
  allowBlankLine: options.allowBlankLine ?? false,
92
87
  allowNotes: options.allowNotes ?? false,
93
88
  reportDynamicTitles: options.reportDynamicTitles ?? false,
89
+ commentKeywords,
90
+ workCommentKeywords: new Set([...DEFAULT_COMMENT_KEYWORDS, ...commentKeywords]),
91
+ commentTicket: defaultMatcher,
94
92
  };
95
93
  }
96
- function checkMarker(marker, state) {
97
- if (!MARKER_RE.test(marker)) {
98
- throw new ConfigError(`the marker for state "${state}" must be uppercase letters, digits, "-" or "_", got "${marker}".`);
99
- }
100
- return marker;
101
- }
102
94
  //# sourceMappingURL=options.js.map
@@ -1,4 +1,4 @@
1
- import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema';
2
- /** JSON schema shared by every rule; the same object is accepted in `settings['test-governance']`. */
3
- export declare const optionsSchema: JSONSchema4[];
1
+ import type { Schema } from './validate.js';
2
+ /** The shape of `settings['test-governance']`, the options of `configure()`. */
3
+ export declare const optionsSchema: Schema;
4
4
  //# sourceMappingURL=schema.d.ts.map
@@ -1,11 +1,10 @@
1
- import { BUILTIN_STATES, MARKER_RE, TAG_RE } from './options.js';
1
+ import { BUILTIN_STATE_NAMES, MARKER_RE, TAG_RE, TICKET_PRESETS } from './constants.js';
2
+ const MARKER_HINT = 'an uppercase keyword such as NEEDS-DATA (letters, digits, "-" and "_")';
3
+ const TAG_HINT = 'a tag such as @needs-data';
2
4
  const ticketSpecSchema = {
3
5
  type: 'object',
4
6
  properties: {
5
- preset: {
6
- type: 'string',
7
- enum: ['any', 'jira', 'github', 'gitlab', 'linear', 'azure-devops', 'numeric', 'pattern'],
8
- },
7
+ preset: { type: 'string', enum: [...TICKET_PRESETS] },
9
8
  projects: { type: 'array', items: { type: 'string' } },
10
9
  teams: { type: 'array', items: { type: 'string' } },
11
10
  host: { type: 'string' },
@@ -27,46 +26,56 @@ const stateOverrideSchema = {
27
26
  type: 'object',
28
27
  properties: {
29
28
  enabled: { type: 'boolean' },
30
- marker: { type: 'string', pattern: MARKER_RE.source },
29
+ marker: { type: 'string', pattern: MARKER_RE.source, patternHint: MARKER_HINT },
31
30
  ticket: ticketSchema,
32
31
  },
33
32
  additionalProperties: false,
34
33
  },
35
34
  ],
36
35
  };
37
- /** JSON schema shared by every rule; the same object is accepted in `settings['test-governance']`. */
38
- export const optionsSchema = [
39
- {
40
- type: 'object',
41
- properties: {
42
- testFunctions: { type: 'array', items: { type: 'string' } },
43
- ticket: ticketSchema,
44
- placeholders: { type: 'array', items: { type: 'string' } },
45
- lifecycleTags: { type: 'boolean' },
46
- states: {
36
+ /** The shape of `settings['test-governance']`, the options of `configure()`. */
37
+ export const optionsSchema = {
38
+ type: 'object',
39
+ properties: {
40
+ testFunctions: { type: 'array', items: { type: 'string' } },
41
+ ticket: ticketSchema,
42
+ placeholders: { type: 'array', items: { type: 'string' } },
43
+ lifecycleTags: { type: 'boolean' },
44
+ states: {
45
+ type: 'object',
46
+ properties: Object.fromEntries(BUILTIN_STATE_NAMES.map((name) => [name, stateOverrideSchema])),
47
+ additionalProperties: false,
48
+ },
49
+ customStates: {
50
+ type: 'object',
51
+ additionalProperties: {
47
52
  type: 'object',
48
- properties: Object.fromEntries(Object.keys(BUILTIN_STATES).map((name) => [name, stateOverrideSchema])),
53
+ properties: {
54
+ when: { type: 'string', pattern: TAG_RE.source, patternHint: TAG_HINT },
55
+ marker: { type: 'string', pattern: MARKER_RE.source, patternHint: MARKER_HINT },
56
+ ticket: ticketSchema,
57
+ },
58
+ required: ['when', 'marker'],
49
59
  additionalProperties: false,
50
60
  },
51
- customStates: {
52
- type: 'object',
53
- additionalProperties: {
61
+ },
62
+ comments: {
63
+ anyOf: [
64
+ { type: 'boolean', enum: [false] },
65
+ {
54
66
  type: 'object',
55
67
  properties: {
56
- when: { type: 'string', pattern: TAG_RE.source },
57
- marker: { type: 'string', pattern: MARKER_RE.source },
58
- ticket: ticketSchema,
68
+ keywords: { type: 'array', items: { type: 'string', pattern: MARKER_RE.source, patternHint: MARKER_HINT } },
59
69
  },
60
- required: ['when', 'marker'],
61
70
  additionalProperties: false,
62
71
  },
63
- },
64
- requireTicketForConditional: { type: 'boolean' },
65
- allowBlankLine: { type: 'boolean' },
66
- allowNotes: { type: 'boolean' },
67
- reportDynamicTitles: { type: 'boolean' },
72
+ ],
68
73
  },
69
- additionalProperties: false,
74
+ requireTicketForConditional: { type: 'boolean' },
75
+ allowBlankLine: { type: 'boolean' },
76
+ allowNotes: { type: 'boolean' },
77
+ reportDynamicTitles: { type: 'boolean' },
70
78
  },
71
- ];
79
+ additionalProperties: false,
80
+ };
72
81
  //# sourceMappingURL=schema.js.map
@@ -1,3 +1,4 @@
1
+ import { ConfigError } from './errors.js';
1
2
  export const DEFAULT_PLACEHOLDERS = ['TODO', 'TBD', 'XXX-*', '0', '123', '1234', '12345'];
2
3
  // Every pattern below is anchored and has no nested or overlapping quantifiers, so matching is
3
4
  // linear in the token length. URLs are parsed with `new URL` rather than a regex.
@@ -122,6 +123,9 @@ function compilePreset(spec) {
122
123
  case 'numeric': {
123
124
  const min = spec.minLength ?? 1;
124
125
  const max = spec.maxLength ?? 20;
126
+ if (min > max) {
127
+ throw new ConfigError(`numeric ticket minLength (${min}) is greater than maxLength (${max}).`);
128
+ }
125
129
  return {
126
130
  test: (t) => DIGITS_RE.test(t) && t.length >= min && t.length <= max,
127
131
  expected: min === max ? `a ${min}-digit number` : `a number with ${min} to ${max} digits`,
@@ -129,12 +133,16 @@ function compilePreset(spec) {
129
133
  };
130
134
  }
131
135
  case 'pattern': {
136
+ // The schema can't tie `pattern` to this preset, so check it here.
137
+ if (spec.pattern === undefined) {
138
+ throw new ConfigError('the "pattern" ticket preset needs a `pattern` regex string.');
139
+ }
132
140
  let re;
133
141
  try {
134
142
  re = new RegExp(`^(?:${spec.pattern})$`, spec.flags);
135
143
  }
136
144
  catch (error) {
137
- throw new Error(`eslint-plugin-test-governance: invalid ticket pattern "${spec.pattern}": ${error.message}`);
145
+ throw new ConfigError(`invalid ticket pattern "${spec.pattern}": ${error.message}`);
138
146
  }
139
147
  return { test: (t) => re.test(t), expected: `a ticket matching /${spec.pattern}/`, example: 'TICKET' };
140
148
  }
@@ -0,0 +1,21 @@
1
+ /** The subset of JSON Schema that the options schema uses. */
2
+ export interface Schema {
3
+ type?: 'object' | 'array' | 'string' | 'boolean' | 'integer';
4
+ enum?: readonly unknown[];
5
+ pattern?: string;
6
+ /** How to describe `pattern` in messages, e.g. "an uppercase keyword such as NEEDS-DATA". */
7
+ patternHint?: string;
8
+ minimum?: number;
9
+ minItems?: number;
10
+ items?: Schema;
11
+ properties?: Record<string, Schema>;
12
+ additionalProperties?: boolean | Schema;
13
+ required?: readonly string[];
14
+ anyOf?: readonly Schema[];
15
+ }
16
+ /**
17
+ * Validates `value` against the subset of JSON Schema that the options schema uses and returns
18
+ * readable problems such as `ticket.preset must be one of "any", "jira", … (got "jria")`.
19
+ */
20
+ export declare function validate(value: unknown, schema: Schema, path?: string): string[];
21
+ //# sourceMappingURL=validate.d.ts.map
@@ -0,0 +1,122 @@
1
+ import { editDistance } from './distance.js';
2
+ function kindOf(value) {
3
+ if (value === null)
4
+ return 'null';
5
+ if (Array.isArray(value))
6
+ return 'array';
7
+ if (typeof value === 'number')
8
+ return Number.isInteger(value) ? 'integer' : 'number';
9
+ if (typeof value === 'object')
10
+ return 'object';
11
+ if (typeof value === 'string')
12
+ return 'string';
13
+ if (typeof value === 'boolean')
14
+ return 'boolean';
15
+ return 'other';
16
+ }
17
+ function matchesType(value, type) {
18
+ return kindOf(value) === type;
19
+ }
20
+ const ARTICLES = { object: 'an object', array: 'an array', integer: 'an integer' };
21
+ function describeType(schema) {
22
+ if (schema.enum?.length === 1)
23
+ return show(schema.enum[0]);
24
+ const type = String(schema.type);
25
+ return ARTICLES[type] ?? `a ${type}`;
26
+ }
27
+ /** `JSON.stringify` returns undefined for values JSON can't represent, such as symbols; its typings don't say so. */
28
+ const toJson = JSON.stringify;
29
+ function show(value) {
30
+ if (typeof value === 'function')
31
+ return 'a function';
32
+ const text = toJson(value) ?? String(value);
33
+ return text.length > 40 ? `${text.slice(0, 37)}...` : text;
34
+ }
35
+ function join(path, key) {
36
+ if (typeof key === 'number')
37
+ return `${path}[${key}]`;
38
+ return path ? `${path}.${key}` : key;
39
+ }
40
+ /** The closest known name to a misspelled one, if it's close enough to be a likely typo. */
41
+ function suggest(name, known) {
42
+ const lower = name.toLowerCase();
43
+ let best;
44
+ let bestDistance = Infinity;
45
+ for (const candidate of known) {
46
+ const distance = editDistance(lower, candidate.toLowerCase());
47
+ if (distance < bestDistance) {
48
+ best = candidate;
49
+ bestDistance = distance;
50
+ }
51
+ }
52
+ return bestDistance <= Math.max(2, Math.floor(name.length / 3)) ? best : undefined;
53
+ }
54
+ /**
55
+ * Validates `value` against the subset of JSON Schema that the options schema uses and returns
56
+ * readable problems such as `ticket.preset must be one of "any", "jira", … (got "jria")`.
57
+ */
58
+ export function validate(value, schema, path = '') {
59
+ const label = path || 'options';
60
+ if (schema.anyOf) {
61
+ const branches = schema.anyOf;
62
+ const results = branches.map((branch) => validate(value, branch, path));
63
+ if (results.some((problems) => problems.length === 0))
64
+ return [];
65
+ const sameType = branches.findIndex((branch) => matchesType(value, branch.type));
66
+ if (sameType !== -1)
67
+ return results[sameType];
68
+ return [`${label} must be ${branches.map(describeType).join(' or ')} (got ${show(value)})`];
69
+ }
70
+ if (schema.type !== undefined && !matchesType(value, schema.type)) {
71
+ return [`${label} must be ${describeType(schema)} (got ${show(value)})`];
72
+ }
73
+ if (schema.enum && !schema.enum.includes(value)) {
74
+ const allowed = schema.enum.map(show);
75
+ return [
76
+ allowed.length === 1
77
+ ? `${label} must be ${allowed[0]} (got ${show(value)})`
78
+ : `${label} must be one of ${allowed.join(', ')} (got ${show(value)})`,
79
+ ];
80
+ }
81
+ if (typeof value === 'string' && schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) {
82
+ return [`${label} must be ${schema.patternHint ?? `a string matching /${schema.pattern}/`} (got ${show(value)})`];
83
+ }
84
+ if (typeof value === 'number' && schema.minimum !== undefined && value < schema.minimum) {
85
+ return [`${label} must be at least ${schema.minimum} (got ${value})`];
86
+ }
87
+ if (Array.isArray(value)) {
88
+ const problems = [];
89
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
90
+ problems.push(`${label} must have at least ${schema.minItems} item${schema.minItems === 1 ? '' : 's'}`);
91
+ }
92
+ const { items } = schema;
93
+ if (items)
94
+ value.forEach((item, index) => problems.push(...validate(item, items, join(path, index))));
95
+ return problems;
96
+ }
97
+ if (kindOf(value) === 'object') {
98
+ const object = value;
99
+ const properties = schema.properties ?? {};
100
+ const known = Object.keys(properties);
101
+ const problems = [];
102
+ for (const key of schema.required ?? []) {
103
+ if (object[key] === undefined)
104
+ problems.push(`${join(path, key)} is required`);
105
+ }
106
+ for (const [key, item] of Object.entries(object)) {
107
+ if (item === undefined)
108
+ continue;
109
+ const propertySchema = properties[key] ?? schema.additionalProperties;
110
+ if (typeof propertySchema === 'object') {
111
+ problems.push(...validate(item, propertySchema, join(path, key)));
112
+ }
113
+ else if (propertySchema === false) {
114
+ const hint = suggest(key, known);
115
+ problems.push(`${join(path, key)} is not a known option${hint ? `; did you mean "${hint}"?` : ` (expected one of: ${known.join(', ')})`}`);
116
+ }
117
+ }
118
+ return problems;
119
+ }
120
+ return [];
121
+ }
122
+ //# sourceMappingURL=validate.js.map
@@ -20,12 +20,14 @@ that reuses the same ticket.
20
20
  Incorrect:
21
21
 
22
22
  <!-- example: invalid -->
23
+
23
24
  ```js
24
25
  // SKIP: WEB-500
25
26
  test.fixme('splits shipping', async () => {});
26
27
  ```
27
28
 
28
29
  <!-- example: invalid settings={"lifecycleTags":true} -->
30
+
29
31
  ```js
30
32
  // SKIP: WEB-500
31
33
  test.skip('splits shipping @unstable', async () => {});
@@ -34,12 +36,14 @@ test.skip('splits shipping @unstable', async () => {});
34
36
  Correct:
35
37
 
36
38
  <!-- example: valid -->
39
+
37
40
  ```js
38
41
  // FIXME: WEB-500
39
42
  test.fixme('splits shipping', async () => {});
40
43
  ```
41
44
 
42
45
  <!-- example: valid settings={"lifecycleTags":true} -->
46
+
43
47
  ```js
44
48
  // SKIP: WEB-500
45
49
  // UNSTABLE: WEB-500
@@ -49,4 +53,3 @@ test.skip('splits shipping @unstable', async () => {});
49
53
  ## Options
50
54
 
51
55
  Uses the shared options described in the [README](../../README.md#options).
52
-
@@ -24,11 +24,13 @@ every tag state that is on, including custom states.
24
24
  Incorrect:
25
25
 
26
26
  <!-- example: invalid settings={"lifecycleTags":true} -->
27
+
27
28
  ```js
28
29
  test('filters by category @new @unstable', async () => {});
29
30
  ```
30
31
 
31
32
  <!-- example: invalid settings={"lifecycleTags":true} -->
33
+
32
34
  ```js
33
35
  // SKIP: WEB-9
34
36
  // NEW: WEB-9
@@ -36,11 +38,13 @@ test.skip('filters by category @new', async () => {});
36
38
  ```
37
39
 
38
40
  <!-- example: invalid settings={"lifecycleTags":true} -->
41
+
39
42
  ```js
40
43
  test('invoice download @New', async () => {});
41
44
  ```
42
45
 
43
46
  <!-- example: invalid settings={"lifecycleTags":true} -->
47
+
44
48
  ```js
45
49
  test('loyalty points @unstabel', async () => {});
46
50
  ```
@@ -48,6 +52,7 @@ test('loyalty points @unstabel', async () => {});
48
52
  Correct:
49
53
 
50
54
  <!-- example: valid settings={"lifecycleTags":true} -->
55
+
51
56
  ```js
52
57
  test('filters by category @unstable', async () => {});
53
58
  ```
@@ -55,4 +60,3 @@ test('filters by category @unstable', async () => {});
55
60
  ## Options
56
61
 
57
62
  Uses the shared options described in the [README](../../README.md#options).
58
-
@@ -14,6 +14,16 @@ A leftover marker makes searches for open tickets lie. This rule reports:
14
14
  - a marker above a test, describe or skip call that is not in that marker's state, and
15
15
  - a marker that is not directly above a test, describe or skip call at all, so it has no effect.
16
16
 
17
+ `FIXME:` and `TODO:` are also everyday work comments, so this rule leaves them to
18
+ [`require-ticket-in-comments`](require-ticket-in-comments.md), which requires a ticket in them anywhere
19
+ in a test file. A `// FIXME: WEB-12` above a helper is a tracked work comment, not a stray marker. A
20
+ `// FIXME: refactor this` gets one clear error from that rule rather than a confusing one from this.
21
+
22
+ Other markers that read as a sentence, such as `// SKIP: this one is flaky`, are left alone too: the rule
23
+ counts a comment as prose when its first word is not a valid ticket and more text follows.
24
+
25
+ Files that don't use Playwright, such as application code, are not checked.
26
+
17
27
  The rule offers a suggestion to delete the comment.
18
28
 
19
29
  ## Examples
@@ -21,15 +31,17 @@ The rule offers a suggestion to delete the comment.
21
31
  Incorrect:
22
32
 
23
33
  <!-- example: invalid settings={"lifecycleTags":true} -->
34
+
24
35
  ```js
25
36
  // NEW: WEB-80 promoted last sprint
26
37
  test('saved addresses', async () => {});
27
38
  ```
28
39
 
29
40
  <!-- example: invalid -->
41
+
30
42
  ```js
31
43
  test('checkout', async ({ page }) => {
32
- // FIXME: WEB-81
44
+ // SKIP: WEB-81
33
45
  await page.goto('/checkout');
34
46
  });
35
47
  ```
@@ -37,11 +49,22 @@ test('checkout', async ({ page }) => {
37
49
  Correct:
38
50
 
39
51
  <!-- example: valid settings={"lifecycleTags":true} -->
52
+
40
53
  ```js
41
54
  test('saved addresses', async () => {});
42
55
  ```
43
56
 
44
57
  <!-- example: valid -->
58
+
59
+ ```js
60
+ test('checkout', async ({ page }) => {
61
+ // FIXME: the banner sometimes covers the button on slow machines
62
+ await page.goto('/checkout');
63
+ });
64
+ ```
65
+
66
+ <!-- example: valid -->
67
+
45
68
  ```js
46
69
  // FIXME: WEB-81
47
70
  test.fixme('checkout', async ({ page }) => {
@@ -52,4 +75,3 @@ test.fixme('checkout', async ({ page }) => {
52
75
  ## Options
53
76
 
54
77
  Uses the shared options described in the [README](../../README.md#options).
55
-
@@ -0,0 +1,80 @@
1
+ # Require FIXME and TODO comments in test files to start with a ticket (`test-governance/require-ticket-in-comments`)
2
+
3
+ 📝 Require FIXME and TODO comments in test files to start with a ticket.
4
+
5
+ 💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/Huldoser/eslint-plugin-test-governance#usage).
6
+
7
+ 💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
8
+
9
+ <!-- end auto-generated rule header -->
10
+
11
+ A `// TODO: clean this up` comment is a promise nobody tracks. This rule makes every `FIXME` and
12
+ `TODO` comment in a test file start with the ticket that tracks the work, so the ticket stays the
13
+ source of truth and nothing is forgotten in a comment.
14
+
15
+ ```ts
16
+ // TODO: WEB-512 ✓
17
+ // FIXME: WEB-77, WEB-78 ✓ several tickets, separated by commas
18
+ // TODO: refactor this ✖ no ticket
19
+ // FIXME: WEB-77 flaky locator ✖ only tickets are allowed (allowNotes: true allows the note)
20
+ ```
21
+
22
+ How comments are read:
23
+
24
+ - The keyword starts the comment (or a line of a block comment). `TODO` and `FIXME` in capitals
25
+ count with or without a colon. Other casings, such as `todo:`, need the colon, so a sentence that
26
+ happens to start with "Todo" is left alone.
27
+ - The tickets follow the same rules as markers: the shared ticket format, placeholders such as `TBD`
28
+ rejected, and no text after the tickets unless `allowNotes` is set.
29
+ - A `// FIXME:` marker directly above a `test.fixme(...)` is checked by
30
+ [`require-ticket`](require-ticket.md) instead, so it is reported once.
31
+ - Only files that use Playwright (a Playwright import or a call to a test function) are checked.
32
+ Application code is left alone, even if your config applies the plugin to it.
33
+
34
+ ## Examples
35
+
36
+ Incorrect:
37
+
38
+ <!-- example: invalid -->
39
+
40
+ ```js
41
+ test('checkout', async ({ page }) => {
42
+ // FIXME: this locator breaks on mobile
43
+ await page.click('#pay');
44
+ });
45
+ ```
46
+
47
+ <!-- example: invalid -->
48
+
49
+ ```js
50
+ // TODO: TBD
51
+ test('refund', async ({ page }) => {});
52
+ ```
53
+
54
+ Correct:
55
+
56
+ <!-- example: valid -->
57
+
58
+ ```js
59
+ test('checkout', async ({ page }) => {
60
+ // FIXME: WEB-81
61
+ await page.click('#pay');
62
+ });
63
+ ```
64
+
65
+ <!-- example: valid settings={"comments":{"keywords":["FIXME"]}} -->
66
+
67
+ ```js
68
+ // TODO: add a refund test once the API is stable
69
+ test('checkout', async ({ page }) => {});
70
+ ```
71
+
72
+ ## Options
73
+
74
+ This rule has no options of its own. It reads the shared options described in the
75
+ [README](../../README.md#options):
76
+
77
+ - `comments`: the keywords to check, `{ keywords: ['FIXME', 'TODO'] }` by default. Use
78
+ `{ keywords: ['FIXME'] }` to allow free-form `TODO`s, add your own such as `HACK`, or set
79
+ `comments: false` to turn the check off. Turning the rule off in your config works too.
80
+ - `ticket`, `placeholders` and `allowNotes` work as they do for markers.