playwright-toolbox 1.0.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.
Files changed (44) hide show
  1. package/.changeset/README.md +9 -0
  2. package/.changeset/config.json +11 -0
  3. package/README.md +90 -0
  4. package/package.json +26 -0
  5. package/packages/playwright-config/CHANGELOG.md +21 -0
  6. package/packages/playwright-config/README.md +22 -0
  7. package/packages/playwright-config/package.json +47 -0
  8. package/packages/playwright-config/src/index.ts +21 -0
  9. package/packages/playwright-config/tsconfig.json +19 -0
  10. package/packages/playwright-history-dashboard/CHANGELOG.md +21 -0
  11. package/packages/playwright-history-dashboard/README.md +216 -0
  12. package/packages/playwright-history-dashboard/RELEASING.md +249 -0
  13. package/packages/playwright-history-dashboard/dashboard/index.html +2825 -0
  14. package/packages/playwright-history-dashboard/package-lock.json +105 -0
  15. package/packages/playwright-history-dashboard/package.json +56 -0
  16. package/packages/playwright-history-dashboard/pw-dashboard.config.js +22 -0
  17. package/packages/playwright-history-dashboard/scripts/init.ts +95 -0
  18. package/packages/playwright-history-dashboard/src/reporter.ts +376 -0
  19. package/packages/playwright-history-dashboard/tsconfig.json +19 -0
  20. package/packages/pw-standard/.eslintrc.js +23 -0
  21. package/packages/pw-standard/CHANGELOG.md +31 -0
  22. package/packages/pw-standard/README.md +50 -0
  23. package/packages/pw-standard/jest.config.js +28 -0
  24. package/packages/pw-standard/package.json +86 -0
  25. package/packages/pw-standard/src/base/index.ts +19 -0
  26. package/packages/pw-standard/src/eslint/index.ts +91 -0
  27. package/packages/pw-standard/src/eslint/rules/no-brittle-selectors.ts +53 -0
  28. package/packages/pw-standard/src/eslint/rules/no-focused-tests.ts +61 -0
  29. package/packages/pw-standard/src/eslint/rules/no-page-pause.ts +37 -0
  30. package/packages/pw-standard/src/eslint/rules/no-wait-for-timeout.ts +34 -0
  31. package/packages/pw-standard/src/eslint/rules/prefer-web-first-assertions.ts +90 -0
  32. package/packages/pw-standard/src/eslint/rules/require-test-description.ts +159 -0
  33. package/packages/pw-standard/src/eslint/types.ts +20 -0
  34. package/packages/pw-standard/src/eslint/utils/ast.ts +59 -0
  35. package/packages/pw-standard/src/index.ts +13 -0
  36. package/packages/pw-standard/src/playwright/index.ts +6 -0
  37. package/packages/pw-standard/src/tsconfig/base.json +21 -0
  38. package/packages/pw-standard/src/tsconfig/strict.json +11 -0
  39. package/packages/pw-standard/tests/eslint/no-brittle-selectors.test.ts +34 -0
  40. package/packages/pw-standard/tests/eslint/no-page-pause-and-focused.test.ts +41 -0
  41. package/packages/pw-standard/tests/eslint/no-wait-for-timeout.test.ts +30 -0
  42. package/packages/pw-standard/tests/eslint/prefer-web-first-assertions.test.ts +25 -0
  43. package/packages/pw-standard/tests/eslint/require-test-description.test.ts +49 -0
  44. package/packages/pw-standard/tsconfig.json +24 -0
@@ -0,0 +1,159 @@
1
+ import { Rule } from 'eslint';
2
+ import { CallExpression, Literal, TemplateLiteral } from 'estree';
3
+ import { RuleModule } from '../types';
4
+
5
+ const TEST_FUNCTIONS = new Set(['test', 'it']);
6
+
7
+ const VAGUE_PATTERNS: RegExp[] = [
8
+ /^test\s*\d*$/i,
9
+ /^it\s*\d*$/i,
10
+ /^spec\s*\d*$/i,
11
+ /^(todo|fixme|wip)$/i,
12
+ /^\d+$/,
13
+ ];
14
+
15
+ const MIN_DESCRIPTION_LENGTH = 10;
16
+ const TEST_ID_AT_END_PATTERN = /\bnrt-\d+\s*$/i;
17
+
18
+ function getMeaningfulDescription(description: string): string {
19
+ return description
20
+ .replace(TEST_ID_AT_END_PATTERN, '')
21
+ .replace(/\s+/g, ' ')
22
+ .trim();
23
+ }
24
+
25
+ const rule: RuleModule = {
26
+ meta: {
27
+ type: 'suggestion',
28
+ docs: {
29
+ description: 'Require meaningful, descriptive test names',
30
+ category: 'Best Practices',
31
+ recommended: true,
32
+ url: 'https://github.com/acahet-automation-org/playwright-standards/blob/main/docs/rules/require-test-description.md',
33
+ },
34
+ messages: {
35
+ missingDescription:
36
+ 'test() must have a description as the first argument.',
37
+ missingTestId:
38
+ 'Test description "{{name}}" must end with a test ID in the format nrt-123.',
39
+ tooShort:
40
+ 'Test description "{{name}}" is too short (min {{min}} characters). Describe observable behaviour.',
41
+ vagueDescription:
42
+ 'Test description "{{name}}" is too vague. Describe what the user sees or does.',
43
+ },
44
+ schema: [
45
+ {
46
+ type: 'object',
47
+ properties: {
48
+ minLength: { type: 'number', minimum: 1 },
49
+ },
50
+ additionalProperties: false,
51
+ },
52
+ ],
53
+ },
54
+
55
+ create(context: Rule.RuleContext): Rule.RuleListener {
56
+ const options = context.options[0] ?? {};
57
+ const minLength: number = options.minLength ?? MIN_DESCRIPTION_LENGTH;
58
+
59
+ return {
60
+ CallExpression(node: CallExpression) {
61
+ if (node.callee.type !== 'Identifier') return;
62
+ const name = (node.callee as { name: string }).name;
63
+ if (!TEST_FUNCTIONS.has(name)) return;
64
+
65
+ const firstArg = node.arguments[0];
66
+
67
+ if (!firstArg) {
68
+ context.report({ node, messageId: 'missingDescription' });
69
+ return;
70
+ }
71
+
72
+ if (firstArg.type === 'TemplateLiteral') {
73
+ const cooked = (firstArg as TemplateLiteral).quasis
74
+ .map((q) => q.value.cooked ?? '')
75
+ .join('');
76
+ const description = cooked.trim();
77
+ const meaningfulDescription =
78
+ getMeaningfulDescription(description);
79
+
80
+ if (!TEST_ID_AT_END_PATTERN.test(description)) {
81
+ context.report({
82
+ node: firstArg,
83
+ messageId: 'missingTestId',
84
+ data: { name: description },
85
+ });
86
+ return;
87
+ }
88
+
89
+ if (
90
+ VAGUE_PATTERNS.some((pattern) =>
91
+ pattern.test(meaningfulDescription),
92
+ )
93
+ ) {
94
+ context.report({
95
+ node: firstArg,
96
+ messageId: 'vagueDescription',
97
+ data: { name: meaningfulDescription },
98
+ });
99
+ return;
100
+ }
101
+
102
+ if (meaningfulDescription.length < minLength) {
103
+ context.report({
104
+ node: firstArg,
105
+ messageId: 'tooShort',
106
+ data: {
107
+ name: meaningfulDescription,
108
+ min: String(minLength),
109
+ },
110
+ });
111
+ }
112
+ return;
113
+ }
114
+
115
+ if (firstArg.type !== 'Literal') return;
116
+
117
+ const description = String((firstArg as Literal).value).trim();
118
+ const meaningfulDescription =
119
+ getMeaningfulDescription(description);
120
+
121
+ if (!TEST_ID_AT_END_PATTERN.test(description)) {
122
+ context.report({
123
+ node: firstArg,
124
+ messageId: 'missingTestId',
125
+ data: { name: description },
126
+ });
127
+ return;
128
+ }
129
+
130
+ if (
131
+ VAGUE_PATTERNS.some((pattern) =>
132
+ pattern.test(meaningfulDescription),
133
+ )
134
+ ) {
135
+ context.report({
136
+ node: firstArg,
137
+ messageId: 'vagueDescription',
138
+ data: { name: meaningfulDescription },
139
+ });
140
+ return;
141
+ }
142
+
143
+ if (meaningfulDescription.length < minLength) {
144
+ context.report({
145
+ node: firstArg,
146
+ messageId: 'tooShort',
147
+ data: {
148
+ name: meaningfulDescription,
149
+ min: String(minLength),
150
+ },
151
+ });
152
+ return;
153
+ }
154
+ },
155
+ };
156
+ },
157
+ };
158
+
159
+ export default rule;
@@ -0,0 +1,20 @@
1
+ import { Rule } from 'eslint';
2
+
3
+ export interface RuleModule extends Rule.RuleModule {
4
+ meta: Rule.RuleMetaData & {
5
+ docs: {
6
+ description: string;
7
+ category: string;
8
+ recommended: boolean;
9
+ url?: string;
10
+ };
11
+ };
12
+ }
13
+
14
+ export type RuleMap = Record<string, RuleModule>;
15
+
16
+ export type Severity = 'error' | 'warn' | 'off';
17
+
18
+ export interface ConfigRules {
19
+ [ruleName: string]: Severity | [Severity, ...unknown[]];
20
+ }
@@ -0,0 +1,59 @@
1
+ import { Rule } from 'eslint';
2
+ import { Node, CallExpression, MemberExpression, Identifier } from 'estree';
3
+
4
+ export function isPlaywrightFile(filename: string): boolean {
5
+ return (
6
+ /\.(spec|test)\.[jt]sx?$/.test(filename) ||
7
+ /[/\\](e2e|tests?)[/\\]/.test(filename)
8
+ );
9
+ }
10
+
11
+ export function isWaitForTimeout(node: CallExpression): boolean {
12
+ if (node.callee.type !== 'MemberExpression') return false;
13
+ const callee = node.callee as MemberExpression;
14
+ return (
15
+ callee.property.type === 'Identifier' &&
16
+ (callee.property as Identifier).name === 'waitForTimeout'
17
+ );
18
+ }
19
+
20
+ export function getMethodName(node: CallExpression): string | null {
21
+ if (node.callee.type !== 'MemberExpression') return null;
22
+ const prop = (node.callee as MemberExpression).property;
23
+ return prop.type === 'Identifier' ? (prop as Identifier).name : null;
24
+ }
25
+
26
+ export function getObjectName(node: CallExpression): string | null {
27
+ if (node.callee.type !== 'MemberExpression') return null;
28
+ const obj = (node.callee as MemberExpression).object;
29
+ return obj.type === 'Identifier' ? (obj as Identifier).name : null;
30
+ }
31
+
32
+ export function findAncestor(
33
+ node: Node,
34
+ context: Rule.RuleContext,
35
+ predicate: (n: Node) => boolean,
36
+ ): Node | null {
37
+ const ancestors = context.getAncestors();
38
+ for (let i = ancestors.length - 1; i >= 0; i--) {
39
+ if (predicate(ancestors[i])) return ancestors[i];
40
+ }
41
+ return null;
42
+ }
43
+
44
+ export const BRITTLE_SELECTOR_PATTERNS: RegExp[] = [
45
+ /^\.[\w-]+/,
46
+ /^#[\w-]+/,
47
+ /nth-child/,
48
+ /nth-of-type/,
49
+ /^\/\//,
50
+ /\s*>\s*/,
51
+ /\s*\+\s*/,
52
+ /\s*~\s*/,
53
+ ];
54
+
55
+ export function isBrittleSelector(selector: string): boolean {
56
+ return BRITTLE_SELECTOR_PATTERNS.some((pattern) =>
57
+ pattern.test(selector.trim()),
58
+ );
59
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @acahet/pw-standard
3
+ *
4
+ * Root barrel. Prefer importing from the specific entry point
5
+ * to keep bundle size small:
6
+ *
7
+ * import plugin from '@acahet/pw-standard/eslint'
8
+ * import { baseConfig } from '@acahet/pw-standard/playwright'
9
+ * import { BasePage } from '@acahet/pw-standard/base'
10
+ *
11
+ * This barrel is available for tooling that needs everything at once.
12
+ */
13
+ export { default as eslintPlugin } from './eslint/index';
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Backwards-compatible bridge export.
3
+ *
4
+ * New consumers should import from `@acahet/playwright-config` directly.
5
+ */
6
+ export * from '@acahet/playwright-config';
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "display": "@acahet/pw-standard — base",
4
+ "compilerOptions": {
5
+ "target": "ES2020",
6
+ "module": "commonjs",
7
+ "lib": ["ES2020", "DOM"],
8
+ "moduleResolution": "node",
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true,
16
+ "noUnusedLocals": true,
17
+ "noUnusedParameters": true,
18
+ "noImplicitReturns": true,
19
+ "noFallthroughCasesInSwitch": true
20
+ }
21
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "display": "@acahet/pw-standard — strict",
4
+ "extends": "./base.json",
5
+ "compilerOptions": {
6
+ "strict": true,
7
+ "exactOptionalPropertyTypes": true,
8
+ "noPropertyAccessFromIndexSignature": true,
9
+ "noUncheckedIndexedAccess": true
10
+ }
11
+ }
@@ -0,0 +1,34 @@
1
+ import { RuleTester } from 'eslint';
2
+ import rule from '../../src/eslint/rules/no-brittle-selectors';
3
+
4
+ const tester = new RuleTester({
5
+ parserOptions: { ecmaVersion: 2020, sourceType: 'module' },
6
+ });
7
+
8
+ tester.run('no-brittle-selectors', rule, {
9
+ valid: [
10
+ {
11
+ code: `async function t() { await page.getByTestId('submit-button').click(); }`,
12
+ },
13
+ {
14
+ code: `async function t() { await page.getByRole('button', { name: 'Submit' }).click(); }`,
15
+ },
16
+ {
17
+ code: `async function t() { await page.locator('[data-testid="modal"]').click(); }`,
18
+ },
19
+ ],
20
+ invalid: [
21
+ {
22
+ code: `async function t() { await page.locator('.btn-primary').click(); }`,
23
+ errors: [{ messageId: 'brittleSelector' }],
24
+ },
25
+ {
26
+ code: `async function t() { await page.locator('#submit-btn').click(); }`,
27
+ errors: [{ messageId: 'brittleSelector' }],
28
+ },
29
+ {
30
+ code: `async function t() { await page.locator('div > span > button').click(); }`,
31
+ errors: [{ messageId: 'brittleSelector' }],
32
+ },
33
+ ],
34
+ });
@@ -0,0 +1,41 @@
1
+ import { RuleTester } from 'eslint';
2
+ import noPagePause from '../../src/eslint/rules/no-page-pause';
3
+ import noFocusedTests from '../../src/eslint/rules/no-focused-tests';
4
+
5
+ const tester = new RuleTester({
6
+ parserOptions: { ecmaVersion: 2020, sourceType: 'module' },
7
+ });
8
+
9
+ tester.run('no-page-pause', noPagePause, {
10
+ valid: [{ code: `async function t() { await page.goto('/'); }` }],
11
+ invalid: [
12
+ {
13
+ code: `async function t() { await page.pause(); }`,
14
+ errors: [{ messageId: 'noPagePause' }],
15
+ },
16
+ ],
17
+ });
18
+
19
+ tester.run('no-focused-tests', noFocusedTests, {
20
+ valid: [
21
+ { code: `test('a normal test', async () => {});` },
22
+ {
23
+ code: `test.skip('skipped test', async () => {});`,
24
+ options: [{ allowSkip: true }],
25
+ },
26
+ ],
27
+ invalid: [
28
+ {
29
+ code: `test.only('focused test', async () => {});`,
30
+ errors: [{ messageId: 'noOnly' }],
31
+ },
32
+ {
33
+ code: `describe.only('focused describe', () => {});`,
34
+ errors: [{ messageId: 'noOnly' }],
35
+ },
36
+ {
37
+ code: `test.skip('skipped test', async () => {});`,
38
+ errors: [{ messageId: 'noSkip' }],
39
+ },
40
+ ],
41
+ });
@@ -0,0 +1,30 @@
1
+ import { RuleTester } from 'eslint';
2
+ import rule from '../../src/eslint/rules/no-wait-for-timeout';
3
+
4
+ const tester = new RuleTester({
5
+ parserOptions: { ecmaVersion: 2020, sourceType: 'module' },
6
+ });
7
+
8
+ tester.run('no-wait-for-timeout', rule, {
9
+ valid: [
10
+ {
11
+ code: `async function t() { await page.locator('[data-testid="modal"]').waitFor(); }`,
12
+ },
13
+ {
14
+ code: `async function t() { await expect(page.getByTestId('spinner')).toBeHidden(); }`,
15
+ },
16
+ {
17
+ code: `async function t() { await page.waitForResponse(r => r.url().includes('/api')); }`,
18
+ },
19
+ ],
20
+ invalid: [
21
+ {
22
+ code: `async function t() { await page.waitForTimeout(2000); }`,
23
+ errors: [{ messageId: 'noWaitForTimeout' }],
24
+ },
25
+ {
26
+ code: `async function t() { await frame.waitForTimeout(500); }`,
27
+ errors: [{ messageId: 'noWaitForTimeout' }],
28
+ },
29
+ ],
30
+ });
@@ -0,0 +1,25 @@
1
+ import { RuleTester } from 'eslint';
2
+ import rule from '../../src/eslint/rules/prefer-web-first-assertions';
3
+
4
+ const tester = new RuleTester({
5
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
6
+ });
7
+
8
+ tester.run('prefer-web-first-assertions', rule, {
9
+ valid: [
10
+ {
11
+ code: `await expect(page.getByTestId('title')).toHaveText('Hello');`,
12
+ },
13
+ { code: `const text = await page.locator('h1').innerText();` },
14
+ ],
15
+ invalid: [
16
+ {
17
+ code: `expect(await page.locator('h1').innerText()).toBe('Hello');`,
18
+ errors: [{ messageId: 'preferWebFirst' }],
19
+ },
20
+ {
21
+ code: `expect(await page.locator('button').isVisible()).toBe(true);`,
22
+ errors: [{ messageId: 'preferWebFirst' }],
23
+ },
24
+ ],
25
+ });
@@ -0,0 +1,49 @@
1
+ import { RuleTester } from 'eslint';
2
+ import rule from '../../src/eslint/rules/require-test-description';
3
+
4
+ const tester = new RuleTester({
5
+ parserOptions: { ecmaVersion: 2020, sourceType: 'module' },
6
+ });
7
+
8
+ tester.run('require-test-description', rule, {
9
+ valid: [
10
+ {
11
+ code: `test('user can submit the login form with valid credentials nrt-101', async () => {});`,
12
+ },
13
+ {
14
+ code: `it('admin sees the delete button when viewing another user nrt-202', async () => {});`,
15
+ },
16
+ {
17
+ // custom minLength option
18
+ code: `test('okay nrt-303', async () => {});`,
19
+ options: [{ minLength: 3 }],
20
+ },
21
+ ],
22
+ invalid: [
23
+ {
24
+ // only test ID at the end leaves no meaningful description → tooShort
25
+ code: `test('nrt-1', async () => {});`,
26
+ errors: [{ messageId: 'tooShort' }],
27
+ },
28
+ {
29
+ // missing nrt-xxx suffix → missingTestId
30
+ code: `test('user can submit the login form with valid credentials', async () => {});`,
31
+ errors: [{ messageId: 'missingTestId' }],
32
+ },
33
+ {
34
+ // id not at the end → missingTestId
35
+ code: `test('nrt-404 user can submit the login form with valid credentials', async () => {});`,
36
+ errors: [{ messageId: 'missingTestId' }],
37
+ },
38
+ {
39
+ // matches VAGUE_PATTERNS after removing trailing test ID → vagueDescription
40
+ code: `test('fixme nrt-404', async () => {});`,
41
+ errors: [{ messageId: 'vagueDescription' }],
42
+ },
43
+ {
44
+ // matches /^test\s*\d*$/i after removing trailing test ID → vagueDescription
45
+ code: `test('test 1 nrt-505', async () => {});`,
46
+ errors: [{ messageId: 'vagueDescription' }],
47
+ },
48
+ ],
49
+ });
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "lib": ["ES2020"],
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "sourceMap": true,
9
+ "outDir": "./dist",
10
+ "rootDir": "./src",
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "resolveJsonModule": true,
16
+ "paths": {
17
+ "@acahet/pw-standard/eslint": ["./src/eslint/index.ts"],
18
+ "@acahet/pw-standard/playwright": ["./src/playwright/index.ts"],
19
+ "@acahet/pw-standard/base": ["./src/base/index.ts"]
20
+ }
21
+ },
22
+ "include": ["src/**/*"],
23
+ "exclude": ["node_modules", "dist", "tests"]
24
+ }