Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ and append Prettier last; pull individual concerns when you want finer control.
| `eslint/react` | React JSX/TSX components + hooks rules + jsx-a11y recommended (curated, anti-slop) |
| `eslint/cloudflare-workers` | Workers runtime globals + no-Node-builtin guards |
| `eslint/testing` | Spec/fixture relaxations |
| `eslint/playwright` | Playwright specs (recommended + curated matcher/diagnostic rules) and configs (mandatory action/navigation timeouts) |
| `eslint/strict-size` | Opt-in: promotes `max-lines` + `max-lines-per-function` from `warn` to `error` (append after a preset once the repo is under budget) |
| `eslint/ignores` | Shared build-artifact ignores |
| `eslint/globals` | Raw globals maps (Workers + webGUI) |
Expand Down Expand Up @@ -152,6 +153,50 @@ Layer it after the base/core concerns and before prettier.
> rules are AST-based and React-version-independent, so they run on plain `.jsx`
> with no type information.

### Playwright

```js
import playwright from "@unraid/js-standards/eslint/playwright";
```

Two halves — the specs and the config that runs them.

**Specs** (`**/*.{spec,test}.*`) get `eslint-plugin-playwright`'s recommended set
(conditional logic in tests, forgotten `await` on assertions,
`page.waitForTimeout` sleeps, focused/skipped tests left behind) plus a curated
tier the plugin ships but leaves out of recommended, all at `error`: matcher
choices that make a failure legible (`prefer-to-be`, `prefer-comparison-matcher`,
…), `require-to-pass-timeout`, `no-commented-out-tests`, locator quality
(`no-raw-locators`, `no-nth-methods`, `prefer-native-locators`,
`no-get-by-title`), and structure (`require-hook`, `require-top-level-describe`,
`no-slowed-test`).

This states the target rather than the current state of any one suite. A repo
adopting mid-stream downgrades specific rules in its own config, which keeps the
exception visible and local instead of hidden in the shared baseline.

Pure convention stays **off** — `prefer-lowercase-title` (style), `require-tags`
and `no-restricted-*` (need a project vocabulary), `require-soft-assertions`
(changes failure semantics), `max-expects` (an arbitrary budget), and `no-hooks`
(contradicts `require-hook`).

> The plugin's rules key off bare `test`/`expect` identifiers. In a repo that
> also runs Vitest or Jest under that glob, re-scope `files` on the spec entry to
> where Playwright actually owns — otherwise the rules give Playwright advice
> about a unit test.

**Configs** (`playwright*.config.*`) must set `use.actionTimeout` and
`use.navigationTimeout` explicitly.

Playwright defaults both to `0`, which means _no timeout_ rather than a sensible
one. `expect()` has its own default, so a config that sets `expect.timeout` looks
bounded — but that ceiling only covers assertions. A bare `.click()` or `.goto()`
on an element that never becomes actionable waits for the entire test timeout.

On a suite with a generous per-test budget that is a silent blackout: no output,
no failing assertion, and a final error naming the test rather than the call that
hung. Setting both turns it into a fast failure that names the locator.

## CSS conventions

Prefer framework-native styles first: Vue/Nuxt component CSS belongs in
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"./eslint/react": "./src/eslint/react.js",
"./eslint/cloudflare-workers": "./src/eslint/cloudflare-workers.js",
"./eslint/testing": "./src/eslint/testing.js",
"./eslint/playwright": "./src/eslint/playwright.js",
"./eslint/oxlint": "./src/eslint/oxlint.js",
"./eslint/ignores": "./src/eslint/ignores.js",
"./eslint/globals": "./src/eslint/globals.js",
Expand Down Expand Up @@ -57,6 +58,7 @@
"eslint-plugin-import-x": "^4.17.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-oxlint": "^1.72.0",
"eslint-plugin-playwright": "^2.11.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-sonarjs": "^4.1.0",
"eslint-plugin-unicorn": "^72.0.0",
Expand Down
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions src/eslint/globs.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,18 @@ export const TOOLING_FILES = [
"**/scripts/**",
"eslint.config.*",
];

/** Playwright config files, which carry their own timeout obligations. */
export const PLAYWRIGHT_CONFIG_FILES = [
"**/playwright*.config.{ts,mts,cts,js,mjs,cjs}",
];

/**
* Playwright specs — mirrors Playwright's own default `testMatch`.
*
* The plugin's rules key off bare `test`/`expect` identifiers, so in a repo that
* also runs Vitest or Jest under this pattern, re-scope `files` on the spec entry
* to wherever Playwright actually owns (`e2e/**`, `**\/*.e2e.spec.ts`, …).
* Otherwise the rules give Playwright advice about a unit test.
*/
export const PLAYWRIGHT_SPEC_FILES = ["**/*.{spec,test}.{ts,tsx,js,mjs,cjs}"];
Comment on lines +31 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Playwright default testMatch pattern file extensions

💡 Result:

By default, Playwright uses the following glob pattern to identify test files: /*.@(spec|test).?(c|m)[jt]s?(x) [1][2][3]. This pattern includes the following file types and naming conventions: - File extensions:.js,.ts,.mjs,.cjs,.jsx, and.tsx [1][2]. - Naming conventions: The files must end with either.spec or.test before the file extension [1][3]. For example, files named login-screen.spec.ts or user-login.test.js would be matched by this default pattern [1][2]. Matching is performed against the absolute file path, and strings provided to the testMatch configuration option are treated as glob patterns [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Tracked globs.js locations:\n'
git ls-files | grep -F 'src/eslint/globs.js' || true

printf '\nRelevant contents:\n'
if [ -f src/eslint/globs.js ]; then
  nl -ba src/eslint/globs.js | sed -n '1,80p'
fi

printf '\nSearch for PLAYWRIGHT_SPEC_FILES usage:\n'
rg -n "PLAYWRIGHT_SPEC_FILES|playwrightSpecFiles|playwright" src/eslint || true

printf '\nPackage references:\n'
rg -n '"playwright"|eslint-plugin-playwright|playwright' package.json package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null || true

printf '\nParse Playwright default-glob-like extension set vs current glob extensions:\n'
python3 - <<'PY'
from pathlib import Path
import re

txt = Path('src/eslint/globs.js').read_text() if Path('src/eslint/globs.js').exists() else ''
m = re.search(r'PLAYWRIGHT_SPEC_FILES\s*=\s*\[([^]]+)\]', txt)
current = set(re.findall(r'(?<=\\.)[A-Za-z0-9]+(?=[.,}])', m.group(1))) if m else set()
playwright_doc_extensions = {'js','ts','mjs','cjs','jsx','tsx'}

print('current_extensions_in_glob=', sorted(current))
print('doc_extensions=', sorted(playwright_doc_extensions))
print('missing_from_glob=', sorted(playwright_doc_extensions - current))
PY

Repository: unraid/js-standards

Length of output: 265


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Relevant contents:\n'
if [ -f src/eslint/globs.js ]; then
  awk '{printf "%6d\t%s\n", NR, $0}' src/eslint/globs.js | sed -n '1,80p'
fi

printf '\nSearch for PLAYWRIGHT_SPEC_FILES usage:\n'
rg -n "PLAYWRIGHT_SPEC_FILES|playwrightSpecFiles|playwright" src/eslint || true

printf '\nPackage references:\n'
for f in package.json package-lock.json pnpm-lock.yaml yarn.lock; do
  if [ -f "$f" ]; then
    rg -n '"playwright"|eslint-plugin-playwright|playwright' "$f" || true
  fi
done

printf '\nCompare current glob extensions with Playwright docs extension list:\n'
python3 - <<'PY'
from pathlib import Path
import re

txt = Path('src/eslint/globs.js').read_text()
m = re.search(r'PLAYWRIGHT_SPEC_FILES\s*=\s*\[([^]]+)\]', txt, re.S)
current = set(re.findall(r'(?<=\\)[A-Za-z0-9]+(?=[.,}])', m.group(1))) if m else set()
doc = {'js','ts','mjs','cjs','jsx','tsx'}

print('current_extensions_in_glob=', sorted(current))
print('doc_extensions=', sorted(doc))
print('missing_from_glob=', sorted(doc - current))
PY

Repository: unraid/js-standards

Length of output: 4316


Extend PLAYWRIGHT_SPEC_FILES to match Playwright’s default testMatch.

The document states this glob mirrors Playwright’s default, but Playwright also matches Playwright spec files with .mts, .cts, and .jsx. Add those extensions to keep the pattern and comment consistent; otherwise .mts/.cts/.jsx specs may not get Playwright lint rules.

♻️ Proposed glob extension
-export const PLAYWRIGHT_SPEC_FILES = ["**/*.{spec,test}.{ts,tsx,js,mjs,cjs}"];
+export const PLAYWRIGHT_SPEC_FILES = [
+  "**/*.{spec,test}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
+];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Playwright specs mirrors Playwright's own default `testMatch`.
*
* The plugin's rules key off bare `test`/`expect` identifiers, so in a repo that
* also runs Vitest or Jest under this pattern, re-scope `files` on the spec entry
* to wherever Playwright actually owns (`e2e/**`, `**\/*.e2e.spec.ts`, ).
* Otherwise the rules give Playwright advice about a unit test.
*/
export const PLAYWRIGHT_SPEC_FILES = ["**/*.{spec,test}.{ts,tsx,js,mjs,cjs}"];
/**
* Playwright specs mirrors Playwright's own default `testMatch`.
*
* The plugin's rules key off bare `test`/`expect` identifiers, so in a repo that
* also runs Vitest or Jest under this pattern, re-scope `files` on the spec entry
* to wherever Playwright actually owns (`e2e/**`, `**\/*.e2e.spec.ts`, ).
* Otherwise the rules give Playwright advice about a unit test.
*/
export const PLAYWRIGHT_SPEC_FILES = [
"**/*.{spec,test}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/eslint/globs.js` around lines 31 - 39, Extend the PLAYWRIGHT_SPEC_FILES
glob’s extension set to include mts, cts, and jsx alongside the existing
TypeScript, JavaScript, and module variants, preserving its current spec/test
filename matching behavior.

167 changes: 167 additions & 0 deletions src/eslint/playwright.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* Concern: Playwright. Two halves — the specs and the config that runs them.
*
* Specs get `eslint-plugin-playwright`'s recommended set (conditional logic in
* tests, forgotten `await` on assertions, `page.waitForTimeout` sleeps, skipped
* or focused tests left behind) plus a curated tier the plugin ships but leaves
* out of recommended.
*
* The config half exists because Playwright defaults `actionTimeout` and
* `navigationTimeout` to 0, meaning "no timeout" rather than a sensible one.
* `expect()` has its own default, so a config that sets `expect.timeout` looks
* bounded — but that ceiling only covers assertions. A bare `.click()` or
* `.goto()` on an element that never becomes actionable waits out the whole test
* budget, which on a long-running suite is a silent blackout: no output, no
* failing assertion, and a final error naming the test rather than the call that
* hung.
*/
import playwright from "eslint-plugin-playwright";

import { PLAYWRIGHT_CONFIG_FILES, PLAYWRIGHT_SPEC_FILES } from "./globs.js";

const REQUIRED_TIMEOUTS = ["actionTimeout", "navigationTimeout"];

/** Reads a static property name, ignoring spreads and computed keys. */
function propertyName(node) {
if (node.type !== "Property" || node.computed) return null;
if (node.key.type === "Identifier") return node.key.name;
if (node.key.type === "Literal") return String(node.key.value);
return null;
}

/** Finds a direct property of an object expression by name. */
function findProperty(objectExpression, name) {
return objectExpression.properties.find(
(property) => propertyName(property) === name,
);
}

/**
* Unwraps the config object from `defineConfig({...})`, `export default {...}`,
* or a `satisfies`/`as` wrapper, so the rule reads the same shape either way.
*/
function configObject(node) {
let current = node;
while (current) {
if (
current.type === "TSSatisfiesExpression" ||
current.type === "TSAsExpression"
) {
current = current.expression;
continue;
}
if (
current.type === "CallExpression" &&
current.callee.type === "Identifier" &&
current.callee.name === "defineConfig" &&
current.arguments.length > 0
) {
current = current.arguments[0];
continue;
}
return current.type === "ObjectExpression" ? current : null;
}
return null;
}

const requireActionTimeouts = {
meta: {
type: "problem",
docs: {
description:
"Require explicit actionTimeout and navigationTimeout in a Playwright config",
},
schema: [],
messages: {
missing:
"Playwright config does not set `{{name}}` in `use`. It defaults to 0 (no timeout), so a locator action on an element that never appears hangs for the entire test timeout instead of failing fast and naming the locator.",
},
},
create(context) {
return {
ExportDefaultDeclaration(node) {
const config = configObject(node.declaration);
if (!config) return;
Comment on lines +82 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The rule ignores CommonJS configs, which the file glob still matches.

PLAYWRIGHT_CONFIG_FILES includes .js and .cjs. A CommonJS Playwright config uses module.exports = defineConfig({...}). The rule listens only for ExportDefaultDeclaration, so such a config passes with no report. The failure is silent: the file is linted and reports nothing.

Add a visitor for the module.exports assignment.

♻️ Proposed handler for `module.exports`
       ExportDefaultDeclaration(node) {
         const config = configObject(node.declaration);
         if (!config) return;
+        checkConfig(config);
+      },
+      "AssignmentExpression > MemberExpression.left"(node) {
+        if (
+          node.object.type !== "Identifier" ||
+          node.object.name !== "module" ||
+          node.computed ||
+          node.property.name !== "exports"
+        ) {
+          return;
+        }
+        const config = configObject(node.parent.right);
+        if (config) checkConfig(config);
+      },

Extract the body of the existing visitor into a local checkConfig(config) helper so both entry points share it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/eslint/playwright.js` around lines 82 - 84, Update the rule around
ExportDefaultDeclaration to also inspect CommonJS module.exports assignments
matched by PLAYWRIGHT_CONFIG_FILES. Extract the existing config validation logic
into a local checkConfig(config) helper, then invoke it for both default exports
and module.exports values, including defineConfig({...}) wrappers.


const useProperty = findProperty(config, "use");
// A config with no `use` block at all still inherits both defaults, so
// report against the config object rather than skipping it.
const target =
useProperty && useProperty.value.type === "ObjectExpression"
? useProperty.value
: null;

for (const name of REQUIRED_TIMEOUTS) {
if (target && findProperty(target, name)) continue;
context.report({
node: target ?? config,
messageId: "missing",
data: { name },
});
}
},
};
},
};

const configPlugin = {
meta: { name: "playwright-config" },
rules: { "require-action-timeouts": requireActionTimeouts },
};

const recommended = playwright.configs["flat/recommended"];

/**
* Rules the plugin ships but leaves out of recommended.
*
* These are `error` on purpose. This package is the bar new work is written
* against, so the standard states the target rather than the current state of
* any one suite; a repo adopting mid-stream downgrades specific rules in its own
* config, which keeps the exception visible and local instead of hidden in the
* shared baseline.
*
* Left off deliberately, as pure convention rather than correctness:
* `prefer-lowercase-title` (style), `require-tags` and `no-restricted-*` (need a
* project-specific vocabulary), `require-soft-assertions` (changes failure
* semantics), `max-expects` (an arbitrary budget), and `no-hooks` (directly
* contradicts `require-hook` below).
*/
const CURATED_SPEC_RULES = {
// Matcher choice is diagnostic quality: `toBe(true)` reports "expected true,
// received false", while `toBeTruthy()` reports almost nothing useful.
"playwright/prefer-to-be": "error",
"playwright/prefer-to-contain": "error",
"playwright/prefer-comparison-matcher": "error",
"playwright/prefer-equality-matcher": "error",
"playwright/prefer-strict-equal": "error",
"playwright/require-to-throw-message": "error",
// An unbounded `toPass` is the same silent-blackout shape as an unbounded
// action: it retries until the test budget runs out.
"playwright/require-to-pass-timeout": "error",
"playwright/no-commented-out-tests": "error",
// Locator quality. A CSS or nth-based selector binds the test to markup
// structure, so it breaks on a refactor that changed nothing a user can see,
// and it reports "element not found" rather than naming the control.
"playwright/prefer-native-locators": "error",
"playwright/no-nth-methods": "error",
"playwright/no-raw-locators": "error",
"playwright/no-get-by-title": "error",
// Structure: shared setup belongs in a hook, and a spec should declare what it
// covers. `test.slow()` triples a budget instead of fixing what is slow.
"playwright/require-hook": "error",
"playwright/require-top-level-describe": "error",
"playwright/no-slowed-test": "error",
};

export default [
{
...recommended,
files: PLAYWRIGHT_SPEC_FILES,
rules: { ...recommended.rules, ...CURATED_SPEC_RULES },
},
Comment on lines +156 to +161

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Locate other concerns and check which entries set a TypeScript parser.
fd . src/eslint -e js --exec rg -n 'parser|languageOptions|files:' {} \;

echo "=== README mentions of composing the playwright concern ==="
fd -i readme -e md --exec rg -n -C 3 -i 'playwright' {} \;

Repository: unraid/js-standards

Length of output: 1044


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== eslint plugin files ==="
git ls-files src/eslint

echo "=== playwright.js outline/sections ==="
fd -e js playwright.js src/eslint --exec sh -c 'echo "--- $1"; wc -l "$1"; sed -n "1,220p" "$1"' sh {}

echo "=== eslint concern files parser context ==="
for f in $(git ls-files src/eslint -e js); do
  echo "--- $f"
  rg -n 'languageOptions|parser|Files|TS_SOURCE|REACTIVE|VUE|PLAYWRIGHT|recommended|rules:' "$f" -C 2 || true
done

echo "=== exports/config consumers ==="
rg -n "playwright|PLAYWRIGHT_SPEC_FILES|PLAYWRIGHT_CONFIG_FILES|import .*playwright|from .*playwright" .

Repository: unraid/js-standards

Length of output: 13824


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== eslint plugin files ==="
git ls-files src/eslint

echo "=== playwright.js outline/sections ==="
fd -e js playwright.js src/eslint --exec sh -c 'echo "--- $1"; wc -l "$1"; sed -n "1,220p" "$1"' sh {}

echo "=== eslint concern files parser context ==="
for f in $(git ls-files src/eslint -e js); do
  echo "--- $f"
  rg -n 'languageOptions|parser|files|TS_SOURCE|REACT_FILES|VUE|PLAYWRIGHT|recommended|rules:' "$f" -C 2 || true
done

echo "=== exports/config consumers ==="
rg -n "playwright|PLAYWRIGHT_SPEC_FILES|PLAYWRIGHT_CONFIG_FILES|import .*playwright|from .*playwright" .

Repository: unraid/js-standards

Length of output: 13824


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== test helper ==="
for f in $(git ls-files test -z | xargs -0 -n1 sed -n '1,120p'); do
  :
done 2>/dev/null || true

echo "=== README compose sections ==="
sed -n '140,195p' README.md

echo "=== package dependencies relevant ==="
sed -n '1,80p' package.json | rg -n -C 2 'eslint|pnpm|ts|typescript|`@typescript-eslint`|playwright' || true

Repository: unraid/js-standards

Length of output: 5966


🌐 Web query:

@eslint/js 10 recommended config languageOptions parser undefined default parser flat config

💡 Result:

In ESLint 10 (as in previous versions using flat config), the @eslint/js recommended configuration does not define a parser [1][2][3]. Instead, ESLint utilizes a built-in default parser, Espree, when no parser is explicitly specified in the languageOptions property [1]. You are not required to define a parser unless you intend to use a non-default parser (such as @babel/eslint-parser or @typescript-eslint/parser) to handle specific syntax [4][5]. Key points regarding parser configuration in flat config: - Default Behavior: If you omit the languageOptions.parser property, ESLint automatically uses its built-in parser, Espree [1]. - How to Override: If you need a custom parser, you import it and assign it to the languageOptions.parser property within your configuration object [6][4]. - Scope: The @eslint/js recommended configuration (imported from @eslint/js) provides rules, not language parsing settings [1][2][3]. While you often include these rules in the same configuration object as your languageOptions, they remain distinct concerns [1][3]. If you encounter errors suggesting a parser is "undefined" or "required," ensure that your configuration object correctly structures the languageOptions property and verify if any plugins you are using require explicit parser mappings for specific file extensions [6][4][7].

Citations:


🌐 Web query:

ESLint Espree parser can parse TypeScript TypeScript source parse error

💡 Result:

The ESLint default parser, Espree, does not support TypeScript syntax [1][2][3]. When Espree encounters TypeScript-specific constructs—such as type annotations (e.g., : string), interfaces, or enums—it fails because it is designed only for standard ECMAScript [4][5][3]. Attempting to use Espree on TypeScript files will result in a "Parsing error: Unexpected token" [3]. To resolve this issue, you must configure ESLint to use the @typescript-eslint/parser instead of the default Espree parser [3][6]. This parser is specifically designed to convert TypeScript code into an ESTree-compatible format that ESLint can understand [4][5][7]. To fix the error: 1. Install the @typescript-eslint/parser package. 2. Update your ESLint configuration (e.g., .eslintrc.js or eslint.config.js) to set the parser to @typescript-eslint/parser [6][8]. Example configuration update: parser: '@typescript-eslint/parser' If you require advanced features like type-aware linting, you must also configure the parser to reference your project's TypeScript configuration (tsconfig.json) [8]. Using Espree in a TypeScript project is not supported and will lead to unpredictable behavior and linting errors [9].

Citations:


Document TypeScript composition for the Playwright spec concern.

PLAYWRIGHT_SPEC_FILES includes .ts and .tsx, but the spec entry does not set languageOptions.parser. ESLint falls back to its default parser for .ts/.tsx, which reports parse errors on TypeScript syntax. Add this requirement to the README, or make the spec entry composition-aware by excluding those extensions unless composable with a TypeScript parser concern.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/eslint/playwright.js` around lines 156 - 161, Update the Playwright spec
configuration around PLAYWRIGHT_SPEC_FILES so TypeScript and TSX files are not
processed without a compatible TypeScript parser concern; either document and
enforce the required composition in the README or exclude .ts/.tsx from this
entry unless the parser is supplied through composition. Preserve the existing
JavaScript Playwright rules and recommended configuration.

{
files: PLAYWRIGHT_CONFIG_FILES,
plugins: { "playwright-config": configPlugin },
rules: { "playwright-config/require-action-timeouts": "error" },
},
];
80 changes: 80 additions & 0 deletions test/playwright-action-timeouts.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { Linter } from "eslint";
import playwrightConfigConcern from "../src/eslint/playwright.js";

const linter = new Linter();
// Select by rule identity, not position — the concern also ships a spec-file
// entry, and which one comes first is not part of its contract.
const concern = playwrightConfigConcern.find(
(entry) => entry.rules?.["playwright-config/require-action-timeouts"],
);

/** Lint a Playwright config body under the shipped concern's plugin and rules. */
const lintConfig = (source) =>
linter.verify(source, {
plugins: concern.plugins,
rules: concern.rules,
languageOptions: { ecmaVersion: "latest", sourceType: "module" },
});

const missingNames = (source) =>
lintConfig(source)
.map((message) => /`(\w+)`/.exec(message.message)?.[1])
.filter(Boolean);

test("flags a config that sets neither timeout", () => {
// The exact shape that motivated this rule: expect() carries a ceiling, so the
// config looks bounded, while every bare .click() and .goto() is not.
const source = `
import { defineConfig } from "@playwright/test";
export default defineConfig({
timeout: 600000,
expect: { timeout: 60000 },
use: { baseURL: "http://localhost", viewport: { width: 1440, height: 900 } },
});
`;
assert.deepEqual(
missingNames(source).toSorted((left, right) => left.localeCompare(right)),
["actionTimeout", "navigationTimeout"],
);
});

test("flags only the timeout that is absent", () => {
const source = `
import { defineConfig } from "@playwright/test";
export default defineConfig({
use: { actionTimeout: 15000 },
});
`;
assert.deepEqual(missingNames(source), ["navigationTimeout"]);
});

test("accepts a config that sets both timeouts", () => {
const source = `
import { defineConfig } from "@playwright/test";
export default defineConfig({
use: { actionTimeout: 15000, navigationTimeout: 30000 },
});
`;
assert.deepEqual(lintConfig(source), []);
});

test("flags a config with no use block, which still inherits both defaults", () => {
const source = `
import { defineConfig } from "@playwright/test";
export default defineConfig({ timeout: 600000 });
`;
assert.equal(lintConfig(source).length, 2);
});

test("reads a plain default-exported object, not just defineConfig", () => {
const source = `
export default { use: { actionTimeout: 15000, navigationTimeout: 30000 } };
`;
assert.deepEqual(lintConfig(source), []);
});

test("ignores a module that exports something other than a config object", () => {
assert.deepEqual(lintConfig(`export default function build() {}`), []);
});
Loading