eslint-plugin-test-governance 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,49 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.11.0] - 2026-11-01
10
+
11
+ ### Added
12
+
13
+ - Ticket presets for six more trackers: `openproject` (classic `#4821` work package IDs, per-project IDs
14
+ such as `AGILE-1`, and work package links once `host` is set), `bugzilla` (bug numbers, and
15
+ `show_bug.cgi?id=4821` links once `host` is set), `phabricator` (task monograms such as `T4821`, and
16
+ `/T4821` links once `host` is set; Phorge shares the format), `rally` (formatted IDs `US4821`, `DE4821`,
17
+ `TA4821`, `TC4821`), `servicenow` (record numbers such as `STRY0010001` for the `prefixes` you list) and
18
+ `plane` (work-item IDs such as `WEB-142`, and work-item links on `app.plane.so` or a `host`). The
19
+ `rally` preset rejects zero-padded IDs, which is how MITRE ATT&CK writes its technique IDs (`TA0006`).
20
+ - Tickets from test annotations: with `annotations` (default `{ type: 'issue' }`), a valid ticket in
21
+ Playwright's `annotation` of that type or Vitest's `meta` key of that name satisfies the ticket
22
+ requirement, on tests and describes; an invalid one is reported, and `annotations: false` turns it
23
+ off. Comments stay the form that works everywhere; Jest, Mocha, Jasmine, node:test and Cypress have
24
+ no annotations.
25
+
26
+ ### Changed
27
+
28
+ - A `host` value with a scheme, port or path, such as `https://jira.example.com` or `tracker.io:8080`, is
29
+ now a configuration error on every preset that takes `host`, not only on `notion`, `redmine` and
30
+ `youtrack`. Such a value never matched a URL hostname, so it already rejected every link silently.
31
+
32
+ ## [0.10.1] - 2026-10-31
33
+
34
+ ### Fixed
35
+
36
+ - Options that belong to another preset, such as `prefixes` on `trello`, are reported even when a value next
37
+ to them is malformed, so one run shows both the misplaced option and the bad value.
38
+ - A placeholder with several `*` characters, such as `*a*a*a*a*b`, matches in one pass instead of
39
+ backtracking on a long ticket.
40
+ - A details object written as a literal with a spread or a computed key, `{ ...base, tag: '@new' }` or
41
+ `{ ['tag']: '@new' }`, keeps its tags and options; a key or spread that can't be read, such as an object
42
+ imported from another file, is reported with `reportUnreadableTags` instead of being ignored.
43
+ - A tag read from an array element, `TAGS[1]`, or from a constant inside a template, `` `${TAG}` ``, is
44
+ checked like the constant itself.
45
+ - A runtime skip method kept in a local variable, `const { skip } = t`, `const s = testInfo.skip` or
46
+ `const p = pending`, is checked like the method called on the context, in every framework.
47
+ - `node:test`'s `test.describe` and `test.it` are read after `import { test } from 'node:test'`, as they were
48
+ after `require()` or a default import. A state member kept in a variable, `const s = it.skip` or
49
+ `const { skip } = it`, is checked like `it.skip(...)` in every framework. The same holds for a skip called
50
+ on a required module used inline, `require('node:test').test.skip(...)`.
51
+
9
52
  ## [0.10.0] - 2026-10-30
10
53
 
11
54
  ### Added
@@ -396,7 +439,9 @@ First release.
396
439
  - Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
397
440
  fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
398
441
 
399
- [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.0...HEAD
442
+ [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.11.0...HEAD
443
+ [0.11.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.1...v0.11.0
444
+ [0.10.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.0...v0.10.1
400
445
  [0.10.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.9.0...v0.10.0
401
446
  [0.9.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.8.1...v0.9.0
402
447
  [0.8.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.8.0...v0.8.1
package/README.md CHANGED
@@ -242,6 +242,7 @@ and markers. To turn a rule off, set it to `'off'` as usual.
242
242
  | `allowBlankLine` | `boolean` | `false` | Let blank lines separate a marker from its test. |
243
243
  | `allowNotes` | `boolean` | `false` | Allow free text after the tickets, e.g. `// SKIP: TRADE-123 flaky on CI`. Applies to markers and to `FIXME` / `TODO` comments. |
244
244
  | `comments` | `false \| { keywords?: string[] }` | `{ keywords: ['FIXME', 'TODO'] }` | Comment keywords that must start with a ticket anywhere in a test file, e.g. `// TODO: TRADE-123`. Your keywords replace the default ones. `false` allows free-form comments. |
245
+ | `annotations` | `false \| { type?: string }` | `{ type: 'issue' }` | Accept the ticket from a test annotation as well: Playwright's `annotation` of the given type or Vitest's `meta` key of that name. One valid annotation covers every state the test is in, and a describe's covers its tests. See [Tickets from annotations](#tickets-from-annotations). |
245
246
  | `testFunctions` | `string[]` | `['test']` | Names treated as the framework's `test`. Jest and Vitest default to `['test', 'it']`, Mocha, Cypress and WebdriverIO to `['it', 'specify', 'test']`, and Jasmine to `['it']`. Your list replaces the default one, so include those names if you still need them. See below. |
246
247
  | `reportUnreadableTags` | `boolean` | `false` | When a tag state is on, report titles that aren't static text, and tags or details that can't be read in the file, such as ones imported from another module, since their tags can't be checked. |
247
248
 
@@ -274,37 +275,48 @@ a `customStates` entry.
274
275
 
275
276
  ### Ticket presets
276
277
 
277
- | Preset | Accepts | Options |
278
- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
279
- | `any` | `PROJ-123`, `#123`, `owner/repo#123`, any http(s) URL | |
280
- | `jira` | `TRADE-123`, `https://<host>/browse/TRADE-123` (also under a path, as in `/jira/browse/TRADE-123`), board URLs with `selectedIssue=TRADE-123` | `projects`; `host` (any host by default) |
281
- | `github` | `#123`, `owner/repo#123`, `https://github.com/owner/repo/issues/123` (or `/pull/`) | `host` for GitHub Enterprise (`github.com` by default) |
282
- | `gitlab` | `#123`, `group/project#123`, `https://gitlab.com/group/project/-/issues/123` | `host` for self-managed (`gitlab.com` by default) |
283
- | `linear` | `RISK-123`, `https://linear.app/<workspace>/issue/RISK-123/...` | `teams` |
284
- | `azure-devops` | `AB#123`, `#123`, `https://dev.azure.com/<org>/<project>/_workitems/edit/123` | `host` for Azure DevOps Server (`dev.azure.com` and `*.visualstudio.com` by default) |
285
- | `trello` | card links, `https://trello.com/c/nqPiDKmw` with or without the `/9-title` after it | |
286
- | `notion` | Unique IDs such as `TASK-123`, `https://www.notion.so/TASK-123`, page links that end in the page ID (also `https://app.notion.com/p/...`), database links with `?p=<page ID>` | `prefixes`; `host` for a custom domain (`notion.so`, `www.notion.so`, `app.notion.com` and `*.notion.site` by default) |
287
- | `asana` | task links, `https://app.asana.com/0/<project>/<task>` and the newer `https://app.asana.com/1/<workspace>/.../task/<task>` | |
288
- | `clickup` | `CU-86abc123`, `#86abc123`, custom IDs such as `DEV-123`, `https://app.clickup.com/t/86abc123` or `/t/<workspace>/DEV-123`, view links with `?p=<task>` | `prefixes` for custom IDs |
289
- | `redmine` | `#123`, `#123-4` and `#123#note-4` (a note), `##123`; with `host`, `https://<host>/issues/123` (also under a path) | `host`, needed for links |
290
- | `monday` | item links, `https://<account>.monday.com/boards/<board>/pulses/<item>`, and custom keys such as `TIX-001` | `prefixes` for custom keys |
291
- | `youtrack` | `KT-123`, `https://<host>/issue/KT-123/...` or `/projects/KT/issues/KT-123` (also under a path, as in `/youtrack/issue/KT-123`) | `projects`; `host` for Server or a custom domain (`youtrack.jetbrains.com`, `*.youtrack.cloud` and `*.myjetbrains.com` by default) |
292
- | `basecamp` | to-do and card links, `https://3.basecamp.com/<account>/buckets/<project>/todos/<id>` or `/card_tables/cards/<id>` | |
293
- | `shortcut` | `sc-123`, `[sc-123]`, `https://app.shortcut.com/<workspace>/story/123/...` | |
294
- | `url` | links to the hosts you list, and nothing else | `hosts`, required: host names such as `tasks.example.com`, with no scheme, port or path; `*.example.com` matches its subdomains |
295
- | `numeric` | `4821` | `minLength` (1), `maxLength` (20) |
296
- | `pattern` | anything matching your regex, anchored at both ends | `pattern`, `flags` |
297
-
298
- `projects` (Jira, YouTrack), `teams` (Linear) and `prefixes` (Notion, ClickUp, monday.com) are keys as
299
- they appear in tickets, in uppercase: `TRADE` for `TRADE-123`. YouTrack project IDs may also start with a digit, as `4DM`. Without them, any key passes. `host` is a host name such as `jira.example.com`, or
300
- `*.example.com` for every subdomain, as in `{ preset: 'jira', host: '*.atlassian.net' }`. Each preset
278
+ | Preset | Accepts | Options |
279
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
280
+ | `any` | `PROJ-123`, `#123`, `owner/repo#123`, any http(s) URL | |
281
+ | `jira` | `TRADE-123`, `https://<host>/browse/TRADE-123` (also under a path, as in `/jira/browse/TRADE-123`), board URLs with `selectedIssue=TRADE-123` | `projects`; `host` (any host by default) |
282
+ | `github` | `#123`, `owner/repo#123`, `https://github.com/owner/repo/issues/123` (or `/pull/`) | `host` for GitHub Enterprise (`github.com` by default) |
283
+ | `gitlab` | `#123`, `group/project#123`, `https://gitlab.com/group/project/-/issues/123` | `host` for self-managed (`gitlab.com` by default) |
284
+ | `linear` | `RISK-123`, `https://linear.app/<workspace>/issue/RISK-123/...` | `teams` |
285
+ | `azure-devops` | `AB#123`, `#123`, `https://dev.azure.com/<org>/<project>/_workitems/edit/123` | `host` for Azure DevOps Server (`dev.azure.com` and `*.visualstudio.com` by default) |
286
+ | `trello` | card links, `https://trello.com/c/nqPiDKmw` with or without the `/9-title` after it | |
287
+ | `notion` | Unique IDs such as `TASK-123`, `https://www.notion.so/TASK-123`, page links that end in the page ID (also `https://app.notion.com/p/...`), database links with `?p=<page ID>` | `prefixes`; `host` for a custom domain (`notion.so`, `www.notion.so`, `app.notion.com` and `*.notion.site` by default) |
288
+ | `asana` | task links, `https://app.asana.com/0/<project>/<task>` and the newer `https://app.asana.com/1/<workspace>/.../task/<task>` | |
289
+ | `clickup` | `CU-86abc123`, `#86abc123`, custom IDs such as `DEV-123`, `https://app.clickup.com/t/86abc123` or `/t/<workspace>/DEV-123`, view links with `?p=<task>` | `prefixes` for custom IDs |
290
+ | `redmine` | `#123`, `#123-4` and `#123#note-4` (a note), `##123`; with `host`, `https://<host>/issues/123` (also under a path) | `host`, needed for links |
291
+ | `monday` | item links, `https://<account>.monday.com/boards/<board>/pulses/<item>`, and custom keys such as `TIX-001` | `prefixes` for custom keys |
292
+ | `youtrack` | `KT-123`, `https://<host>/issue/KT-123/...` or `/projects/KT/issues/KT-123` (also under a path, as in `/youtrack/issue/KT-123`) | `projects`; `host` for Server or a custom domain (`youtrack.jetbrains.com`, `*.youtrack.cloud` and `*.myjetbrains.com` by default) |
293
+ | `basecamp` | to-do and card links, `https://3.basecamp.com/<account>/buckets/<project>/todos/<id>` or `/card_tables/cards/<id>` | |
294
+ | `shortcut` | `sc-123`, `[sc-123]`, `https://app.shortcut.com/<workspace>/story/123/...` | |
295
+ | `openproject` | `#4821` and per-project IDs such as `AGILE-1`; with `host`, `https://<host>/projects/<identifier>/work_packages/AGILE-1` (also `/work_packages/4821` and the short `/wp/4821`) | `host`, needed for links |
296
+ | `bugzilla` | bug numbers `4821`; with `host`, `https://<host>/show_bug.cgi?id=4821`, also under a path as in `/bugzilla/show_bug.cgi` | `host`, needed for links |
297
+ | `phabricator` | task monograms `T4821`; with `host`, `https://<host>/T4821`. Phorge, Phabricator's maintained fork, shares the format | `host`, needed for links |
298
+ | `rally` | formatted IDs `US4821`, `DE4821`, `TA4821`, `TC4821`, never zero-padded (so MITRE ATT&CK's `TA0006` isn't one) | |
299
+ | `servicenow` | record numbers `STRY0010001` for the prefixes you list, any digit count | `prefixes`, required: record-number prefixes such as `STRY` |
300
+ | `plane` | `WEB-142`; `https://app.plane.so/<workspace>/<project>/issues/WEB-142` (or a UUID), and the same on a `host` for self-hosted | `projects`; `host` for self-hosted (`app.plane.so` by default) |
301
+ | `url` | links to the hosts you list, and nothing else | `hosts`, required: host names such as `tasks.example.com`, with no scheme, port or path; `*.example.com` matches its subdomains |
302
+ | `numeric` | `4821` | `minLength` (1), `maxLength` (20) |
303
+ | `pattern` | anything matching your regex, anchored at both ends | `pattern`, `flags` |
304
+
305
+ `projects` (Jira, YouTrack, Plane), `teams` (Linear) and `prefixes` (Notion, ClickUp, monday.com,
306
+ ServiceNow) are keys as they appear in tickets, in uppercase: `TRADE` for `TRADE-123`, `STRY` for
307
+ `STRY0010001`. YouTrack project IDs may also start with a digit, as `4DM`. Without them, any key passes;
308
+ ServiceNow's list is required. `host` is a host name such as `jira.example.com`, or `*.example.com` for
309
+ every subdomain, as in `{ preset: 'jira', host: '*.atlassian.net' }`. Every preset that takes `host`
310
+ requires a plain host name: a value with a scheme, port or path never matched a link, so it is an error.
311
+ Each preset
301
312
  takes only its own options, so `host` on the `any` preset fails with a clear message instead of being
302
313
  ignored. A `pattern` always matches the whole ticket, so the `g` and `y` flags make no difference and are
303
314
  ignored.
304
315
 
305
316
  A preset rejects links that aren't one ticket in its tracker, such as a Trello board, an Asana project or
306
317
  a Notion database view. Redmine runs on your own server, and `/issues/123` is also GitHub's path, so its
307
- links pass only once `host` is set.
318
+ links pass only once `host` is set. The same holds for OpenProject, Bugzilla and Phabricator, which also
319
+ run on their own servers.
308
320
 
309
321
  For a tracker without a preset, use `url` with its host, for example
310
322
  `{ preset: 'url', hosts: ['www.wrike.com'] }` for Wrike links such as
@@ -329,6 +341,34 @@ testGovernance.configure({
329
341
  The built-in presets are written to run in linear time. A `pattern` preset runs your regex as given,
330
342
  so keep it free of nested quantifiers.
331
343
 
344
+ ### Tickets from annotations
345
+
346
+ A comment never reaches a test report. Playwright and Vitest can carry the ticket in data that does, so
347
+ `annotations` accepts it from there as well:
348
+
349
+ ```js
350
+ test.skip(
351
+ 'rejects a margin order above the buying power',
352
+ {
353
+ annotation: { type: 'issue', description: 'TRADE-123' },
354
+ },
355
+ async () => {},
356
+ );
357
+ ```
358
+
359
+ ```js
360
+ // Vitest: the ticket is the `issue` key of `meta`.
361
+ test.skip('rejects a margin order above the buying power', { meta: { issue: 'TRADE-123' } }, () => {});
362
+ ```
363
+
364
+ The `type` names Playwright's annotation type and Vitest's `meta` key; `'issue'` by default, matched
365
+ exactly. The value must be the ticket and nothing else, as in a marker. One valid annotation covers every
366
+ state the test is in, and a describe's annotation covers its tests. An annotation of another type is
367
+ left alone, and an annotation whose ticket isn't static text is reported with `reportUnreadableTags`.
368
+ `annotations: false` turns it off. A marker comment still works everywhere, and when both are written,
369
+ both must be valid. Jest, Mocha, Jasmine, node:test and Cypress have no annotations, so for them it is
370
+ comments only.
371
+
332
372
  ### Which functions count as `test`
333
373
 
334
374
  In Playwright files:
@@ -21,7 +21,7 @@ export default createRule({
21
21
  create(context, analysis, options) {
22
22
  const { sourceCode } = context;
23
23
  for (const subject of analysis.subjects) {
24
- const missing = missingStates(subject);
24
+ const missing = missingStates(subject, options);
25
25
  if (missing.length === 0)
26
26
  continue;
27
27
  const stray = strayMarkers(subject, options);
@@ -31,7 +31,7 @@ export default createRule({
31
31
  }
32
32
  for (const subject of analysis.subjects) {
33
33
  // A stray marker next to a missing one is a mismatch, reported by marker-matches-state.
34
- if (missingStates(subject).length > 0)
34
+ if (missingStates(subject, options).length > 0)
35
35
  continue;
36
36
  const name = describeSubject(subject).replace(/^This/, 'this');
37
37
  for (const marker of strayMarkers(subject, options)) {
@@ -1,2 +1,2 @@
1
- declare const _default: import("../utils/create-rule.ts").Rule<"missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText" | "missingMarker" | "markerCase" | "dynamicTitle" | "dynamicTags">;
1
+ declare const _default: import("../utils/create-rule.ts").Rule<"missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText" | "missingMarker" | "markerCase" | "dynamicTitle" | "dynamicTags" | "invalidAnnotation" | "unreadAnnotation">;
2
2
  export default _default;
@@ -1,4 +1,4 @@
1
- import { appliesTo, describeSubject, evaluate, headLoc } from "../utils/analyze.js";
1
+ import { annotationTicketsOf, appliesTo, describeSubject, evaluate, hasAnnotationTicket, headLoc, unreadAnnotationsOf, } from "../utils/analyze.js";
2
2
  import { createRule } from "../utils/create-rule.js";
3
3
  export default createRule({
4
4
  meta: {
@@ -20,6 +20,8 @@ export default createRule({
20
20
  removeExtraText: 'Remove `{{text}}`.',
21
21
  dynamicTitle: "This title isn't static text, so its tags can't be checked. Use a string or template literal.",
22
22
  dynamicTags: "These tags can't be read in this file, so they can't be checked. Use strings, or constants declared in this file.",
23
+ invalidAnnotation: "'{{ticket}}' is not a valid ticket for the '{{state}}' state. Expected {{expected}}.",
24
+ unreadAnnotation: "This annotation's ticket isn't static text, so it can't be checked. Use a string, or a constant declared in this file.",
23
25
  },
24
26
  },
25
27
  create(context, analysis, options) {
@@ -28,16 +30,37 @@ export default createRule({
28
30
  const reported = new Set();
29
31
  const reportOnce = (key) => !reported.has(key) && Boolean(reported.add(key));
30
32
  for (const subject of analysis.subjects) {
33
+ const needsTicket = subject.states.some((s) => s.required);
31
34
  if (options.reportUnreadableTags && hasTagStates) {
32
35
  if (subject.dynamicTitle)
33
36
  context.report({ node: subject.node.arguments[0], messageId: 'dynamicTitle' });
34
37
  for (const node of subject.unreadTags)
35
38
  context.report({ node, messageId: 'dynamicTags' });
36
39
  }
40
+ // An annotation whose ticket can't be read may be the one that was supposed to cover the test.
41
+ if (options.reportUnreadableTags && needsTicket) {
42
+ for (const node of unreadAnnotationsOf(subject)) {
43
+ if (reportOnce(`annotation-unread:${node.range[0]}`)) {
44
+ context.report({ node, messageId: 'unreadAnnotation' });
45
+ }
46
+ }
47
+ }
37
48
  for (const { state, required } of subject.states) {
38
49
  if (!required)
39
50
  continue;
40
51
  const result = evaluate(subject, state);
52
+ // Annotations are checked whenever a ticket is needed, whatever the markers say.
53
+ for (const ticket of annotationTicketsOf(subject)) {
54
+ if (state.ticket.check(ticket.text) !== 'ok' && reportOnce(`annotation:${ticket.node.range[0]}`)) {
55
+ context.report({
56
+ node: ticket.node,
57
+ messageId: 'invalidAnnotation',
58
+ data: { ticket: ticket.text, state: state.name, expected: state.ticket.expected },
59
+ });
60
+ }
61
+ }
62
+ if (result.kind === 'missing' && hasAnnotationTicket(subject, state, options))
63
+ continue;
41
64
  if (result.kind !== 'ok' &&
42
65
  result.kind !== 'missing' &&
43
66
  !reportOnce(`${result.marker.comment.range[0]}:${state.name}`)) {
@@ -72,6 +72,13 @@ export interface Subject {
72
72
  skipped: boolean;
73
73
  ownSkip: boolean;
74
74
  markers: Marker[];
75
+ /** Tickets read from annotations of the configured type on this call, with the node to report at. */
76
+ annotationTickets: {
77
+ text: string;
78
+ node: Node;
79
+ }[];
80
+ /** Annotation values of the configured type on this call whose ticket can't be read. */
81
+ unreadTickets: Node[];
75
82
  dynamicTitle: boolean;
76
83
  /** For a runtime function such as Jasmine's `pending()`, its name; messages call the others by their state. */
77
84
  functionName?: string;
@@ -112,8 +119,20 @@ export declare function analyze(sourceCode: SourceCode, options: ResolvedOptions
112
119
  /** Whether `subject` is in `stateName` at all, required or not, including inherited and runtime states. */
113
120
  export declare function appliesTo(subject: Subject, stateName: string): boolean;
114
121
  export declare function evaluate(subject: Subject, state: StateDef): Evaluation;
115
- /** Required states on `subject` that have no marker at all (not even a broken one). */
116
- export declare function missingStates(subject: Subject): StateDef[];
122
+ /** Annotation tickets that can cover `subject`: its own and those of enclosing describes and tests. */
123
+ export declare function annotationTicketsOf(subject: Subject): {
124
+ text: string;
125
+ node: Node;
126
+ }[];
127
+ /** Annotation values whose ticket can't be read, `subject`'s own and those of enclosing describes and tests. */
128
+ export declare function unreadAnnotationsOf(subject: Subject): Node[];
129
+ /** Whether a valid annotation ticket covers `state` for `subject`. */
130
+ export declare function hasAnnotationTicket(subject: Subject, state: StateDef, options: ResolvedOptions): boolean;
131
+ /**
132
+ * Required states on `subject` that have no ticket at all: no marker, not even a broken one, and no
133
+ * valid annotation.
134
+ */
135
+ export declare function missingStates(subject: Subject, options: ResolvedOptions): StateDef[];
117
136
  /**
118
137
  * Whether a marker reads as an ordinary comment rather than a ticket reference, such as
119
138
  * `// FIXME: this breaks on slow machines`: it starts with something that is not a ticket and goes on