@qaguardian/playwright-score 2.0.0 → 2.1.1

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
@@ -1,8 +1,244 @@
1
1
  # Changelog
2
2
 
3
3
  All notable changes to this project are documented here. Any change that
4
- alters what a given suite scores is a new scoring-model version (see
5
- [METHODOLOGY.md](./METHODOLOGY.md)), not a quiet patch.
4
+ alters what a given suite scores *by changing a rule's weight or
5
+ severity* is a new scoring-model version (see
6
+ [METHODOLOGY.md](./METHODOLOGY.md)), not a quiet patch. A release that
7
+ leaves every rule's weight and severity untouched but changes which files
8
+ or which test names get recognized in the first place — discovery and
9
+ parsing accuracy, not grading policy — can still move a suite's score
10
+ without itself being a new model version; 2.1.0 below is exactly that
11
+ case, and says so explicitly.
12
+
13
+ ## 2.1.1 — 2026-09-26
14
+
15
+ Scoring model stays v4. Reviewer follow-ups from the 2.1.0 release review
16
+ (QAG-230) — none of these blocked that release. They close symlink-escape
17
+ gaps a crafted repo could otherwise use, quiet a false warning, and fix a
18
+ false-positive drop the first round of these same fixes introduced. None
19
+ of that changes what an ordinary suite with no symlinked spec files
20
+ scores; the realpath dedupe fix below is the one exception — it can
21
+ change the file/test count for a suite that has a symlinked spec file
22
+ matching discovery alongside its own real target, and says so.
23
+
24
+ ### Fixed
25
+ - **A match escaping the *repository* through a symlink is now dropped —
26
+ scoped to the repo root, not the narrower directory the caller pointed
27
+ at.** `testDirEscapesRepo` (added in 2.1.0) only clamps a config's
28
+ `testDir` itself before it becomes a glob root — it couldn't see a
29
+ symlinked directory the glob then walked *through* on the way to an
30
+ individual match. A `testMatch` entry that names a symlinked directory
31
+ by a literal path segment (not a `**` wildcard) is still followed even
32
+ with glob's `follow: false` (that option only stops `**` from
33
+ *expanding into* a symlinked directory, not a pattern from naming one
34
+ explicitly), so a crafted repo (`tests/evil -> /`, `testMatch:
35
+ ['evil/**/*.ts']`) could make discovery walk arbitrary parts of the
36
+ filesystem while every match still lexically looked like it lived
37
+ under the scanned directory. The same gap let an individually
38
+ symlinked spec file sitting directly in the repo (no `testMatch`
39
+ trickery needed) resolve to, and get scored from, a file outside it.
40
+ `src/index.ts`'s match loop realpath's every candidate match and drops
41
+ it unless it's still contained within the realpath of the **repository
42
+ root** (`safeRealpath(findRepoBoundary(abs))`) — not, as this package
43
+ first shipped it, the narrower directory the caller actually scanned.
44
+ That first version silently dropped a real, common monorepo shape too:
45
+ a spec reachable through an in-repo symlink that resolves to a sibling
46
+ directory *outside* the caller's scanned subpath (one package's specs
47
+ shared with another via a symlink) is legitimate and is kept; only a
48
+ match whose realpath lands outside the repository itself is dropped.
49
+ The same repo-root containment now also applies to a bare glob-pattern
50
+ input (e.g. `tests/*.spec.ts` passed directly to `scorePaths`, not a
51
+ directory it walks itself) — the first version of this fix only
52
+ covered the directory-scan branch, so switching an otherwise-identical
53
+ call from a directory argument to an equivalent glob argument silently
54
+ bypassed the guard entirely.
55
+ - **The walk itself is now pruned at a symlink that escapes the repo
56
+ root, not just filtered after the fact.** The realpath containment
57
+ check above still has to pay to walk all the way through a symlinked
58
+ directory before dropping every match inside it one by one — for a
59
+ large or slow target (`tests/evil -> /`) that's a real cost, not just
60
+ a theoretical one (measured: ~6.4s to fully walk a large symlinked
61
+ directory before this fix). `RepoBoundaryIgnore` (`src/index.ts`) wraps
62
+ glob's own `ignore` matching with a `childrenIgnored` check that stops
63
+ the walk from ever descending into a symlinked directory whose
64
+ realpath lands outside the repo root, only paying the realpath cost at
65
+ an actual symlink (a plain nested directory can't walk outside a root
66
+ its own parent is already inside, so it's never checked).
67
+ - **An in-repo symlinked spec and its own real target are no longer both
68
+ scored, when both independently match discovery under the same scan.**
69
+ Pre-existing, not new to the fixes above: `expandPaths` deduped a
70
+ literal path only against `explicit` inputs, never against another
71
+ `expanded` match that happens to resolve to the same realpath. A
72
+ symlink sitting alongside its target inside the same scanned directory
73
+ (or reachable via two different scan roots in one `scorePaths` call)
74
+ was counted, and scored, twice. Now deduped by realpath, keeping the
75
+ first lexical path in sorted order. **This can change file/test counts
76
+ for a repo that has such a link** — said plainly here rather than
77
+ folded into "doesn't change what a correctly-shaped suite scores",
78
+ since a suite with this exact shape is exactly what changes.
79
+ - **False "testDir resolves outside the repository" warning on a
80
+ nonexistent `testDir` under a symlinked repo path.** `safeRealpath`
81
+ only resolves the portion of a path that exists on disk, so a
82
+ nonexistent `testDir` kept its literal, unresolved form while
83
+ `repoRoot` (which does exist) got fully resolved — on a host where the
84
+ repo itself sits under a symlinked prefix (macOS: `/tmp` ->
85
+ `/private/tmp`), that asymmetry alone made the two sides disagree and
86
+ fired a false escape warning for an ordinary missing `testDir`.
87
+ `testDirEscapesRepo` now checks existence first and returns "does not
88
+ escape" immediately for a `testDir` that isn't on disk — consistent
89
+ with `resolveConfigScopedRoot`'s own existing "nothing to score there"
90
+ handling for the same case.
91
+ - **Escape warning now prints cwd-relative paths**, matching every other
92
+ `configWarning` in this package, instead of the runner's own absolute
93
+ filesystem paths.
94
+ - **A raw glob-pattern input pointing at a different repo than `cwd` no
95
+ longer hard-fails with 0 matches.** The repo-root containment fix above
96
+ used `cwd`'s own repo boundary for a bare glob pattern regardless of
97
+ where the pattern actually pointed — an absolute pattern into a sibling
98
+ repo (`['/abs/repoB/e2e/*.spec.ts']`), or an equivalent `../`-relative
99
+ one, had every match rejected as an "escape" of `cwd`'s repo, where the
100
+ same target passed as a directory already scored fine. `src/index.ts`'s
101
+ glob-input branch now derives its containment boundary from *the
102
+ pattern's own* literal (non-magic) leading-directory prefix, resolved
103
+ against `cwd`, not from `cwd` itself — a literal prefix that lands
104
+ inside another repo entirely is scored against that repo's own
105
+ boundary, while a symlink an in-bounds prefix still reaches that escapes
106
+ *that* repo is still dropped.
107
+ - **An explicit target and a directory scan that reaches it again through
108
+ a symlink are no longer scored twice.** The realpath dedupe added above
109
+ only ever populated its map from `expanded` matches; an `explicit` file
110
+ and a same-realpath `expanded` symlink are two different literal paths,
111
+ so the separate "explicit wins" step (an exact literal-path delete)
112
+ never caught that pairing. The dedupe map is now seeded with
113
+ `explicit`'s own realpaths first, so a same-realpath `expanded` match is
114
+ recognized as a duplicate instead of surviving as a second, independent
115
+ file.
116
+
117
+ Corpus rerun (100 public repos, directory inputs, run on the round-2 build;
118
+ the round-3 fixes only touch glob-pattern inputs): 96/100 repos score
119
+ identically, 4 moved upstream, 0 changed.
120
+
121
+ ## 2.1.0 — 2026-09-24
122
+
123
+ Scoring model stays v4 — nothing here changes a rule's weight or
124
+ severity, only whether a suite is *recognized* correctly in the first
125
+ place (see the note at the top of this file reconciling that with "any
126
+ score-changing release is a new model version"). Every fix below made at
127
+ least one real corpus repo's score more accurate — not uniformly higher,
128
+ and not less strict or more lenient by policy; see "Behaviour change"
129
+ below and VALIDATION.md's "Bugs found this way, and fixed" for the full
130
+ story (corpus repo, exact before/after) behind each one.
131
+
132
+ ### Fixed
133
+ - **Custom `test.extend()` fixture names.** A suite that always declares
134
+ its tests via a custom-named fixture (`const loggedTest =
135
+ test.extend(...)`, imported into every spec file — sometimes through a
136
+ re-export barrel) had every `expect()` inside a completely normal test
137
+ flagged by upstream `eslint-plugin-playwright`'s `no-standalone-expect`
138
+ and sibling rules (which key off the literal identifier `test`, and
139
+ can only dereference a same-file extend chain), and undercounted by
140
+ this package's own test-span/`countTests` logic too.
141
+ `src/test-aliases.ts` resolves these aliases — same-file and
142
+ cross-file, through imports and re-export barrels — and feeds both
143
+ `eslint-plugin-playwright`'s `globalAliases.test` setting and this
144
+ package's own AST rules. Scores rise for affected suites:
145
+ `argos-ci/argos` 57/F → 95/A, `apache/superset` 70/C → 77/C.
146
+ - **Discovery now honours the target's own `playwright.config.*`
147
+ `testDir`/`testMatch`/`testIgnore`.** Spec discovery previously matched
148
+ by filename suffix only, so a directory holding a different test
149
+ runner's files under a similar naming convention got swept in
150
+ alongside the real suite. `src/playwright-config.ts` statically parses
151
+ (never executes — a scored repo's config is untrusted input) the
152
+ nearest `playwright.config.{ts,js,mjs,cjs}` at or above the scanned
153
+ directory and scopes discovery to what the target itself declares.
154
+ When no config is found at all, this falls back to the previous
155
+ filename-based behavior silently, exactly as before this release; when
156
+ a config *is* found but can't be read statically (a dynamic value or
157
+ an unsupported shape, e.g. a `RegExp` `testMatch`), it falls back the
158
+ same way but now also adds an entry to the new, additive, optional
159
+ `ScoreResult.configWarnings` field so the caller can see why. Includes
160
+ three fixes found making this work against real configs: matching
161
+ Playwright's own `createFileMatcher` auto-`**/`-prefix behavior for a
162
+ `testMatch`/`testIgnore` pattern with no leading `**/`; globbing from
163
+ the config's `testDir` rather than the caller's own (possibly narrower
164
+ or differently-rooted) scanned directory; and failing *open* (keeping a
165
+ file) rather than treating an unresolvable relative import as proof a
166
+ file isn't a real spec, since a sparse/partial checkout can legitimately
167
+ be missing a fixtures module the real project has. This moves a
168
+ suite's score in *either* direction depending on whether the old
169
+ filename-only discovery had been over- or under-matching relative to
170
+ what the config itself declares — see "Behaviour change" below for a
171
+ concrete case where it lowers a score.
172
+ - **Discovery no longer misses a plain CommonJS suite.** A follow-on to
173
+ the config-scoped discovery fix above: the safety net that confirms a
174
+ config-matched file transitively imports `@playwright/test` only
175
+ walked ES `import`/`export ... from` syntax, so a suite written
176
+ entirely in CommonJS (`require()`, no `import` anywhere) read as
177
+ *definitive negative evidence* of not being a Playwright spec — the
178
+ opposite of the truth — and hard-failed to 0 files the moment
179
+ config-scoped discovery activated for it. Fixed by also recognizing a
180
+ top-level `require('x')` call the same way as an ES import.
181
+ - **Config discovery and a config's `testDir` can no longer escape the
182
+ scanned repo.** Hardening fixes for a public tool that reads an
183
+ untrusted repo's own config. `findPlaywrightConfig` stops walking
184
+ upward at the first ancestor directory containing `.git` (a directory
185
+ for a normal clone, a file for a worktree/submodule), so a coincidental
186
+ `playwright.config.*` sitting above the actual repo root — a shared CI
187
+ runner, a monorepo checkout nested inside another one — is never picked
188
+ up (this guarantee needs a `.git` somewhere in the ancestor chain).
189
+ The bug this closes: when no `.git` existed anywhere in the ancestor
190
+ chain, the boundary used to clamp a config's `testDir` fell back to the
191
+ filesystem root (or 20 levels up), which is effectively no clamp at
192
+ all, so a `testDir: '/'` could make the glob walk the whole filesystem
193
+ (in the worst case, a hang). For the `testDir` clamp the fallback is
194
+ now the directory actually passed to the scorer, or the config file's
195
+ own directory when that's the wider of the two, never the filesystem
196
+ root. The config search itself (a bounded series of existence checks)
197
+ keeps its wider fallback so monorepo configs above the scanned
198
+ directory are still found.
199
+ Symlinks got the same treatment: a `testDir` is now checked *after
200
+ resolving symlinks* on both sides of the ancestor check, not just
201
+ lexically, so a `testDir` that's a symlink to somewhere outside the
202
+ repo can't launder an escape the plain path comparison would have
203
+ missed (the glob's own `follow` option, already `false` by default for
204
+ `**` patterns, is also now pinned explicitly). An escaping `testDir` —
205
+ absolute (`'/'`), climbing out (`'../../..'`), or via a symlink — gets a
206
+ `configWarnings` entry and falls back to filename-based discovery
207
+ rather than globbing the clamped repo root with testMatch/testIgnore
208
+ patterns written for a different directory. To be clear about the
209
+ actual exposure this closes: a scored *result* was never able to
210
+ include a file whose path lies outside the scan root either way (a
211
+ symlinked file inside the repo is a separate, tracked follow-up) — index.ts always
212
+ filters expanded matches back down to the caller's own requested
213
+ directory — the risk was the walk/glob itself reading (or hanging on)
214
+ parts of the filesystem well outside the repo, not a leaked finding in
215
+ the output. Neither case is exercised by any corpus repo (a legitimate
216
+ config never does this); found and fixed during this release's own
217
+ review, not from a corpus regression.
218
+
219
+ ### Behaviour change
220
+ - **Config-scoped discovery can lower a suite's score, not just raise
221
+ it.** Scoping to what a target's own `playwright.config.*` actually
222
+ declares is strictly more accurate than the old filename-suffix match,
223
+ but "more accurate" isn't the same as "friendlier": `mattermost/
224
+ mattermost` moves **84/B → 79/C (a pass→fail flip)** because its
225
+ top-level config's `testDir` doesn't cover the real, correctly-written
226
+ `upgrade-specs/` suite it runs only through per-project `testDir`
227
+ overrides — `projects[].testDir` isn't read yet (see VALIDATION.md's
228
+ "Known limitations"). A future config parser that reads
229
+ `projects[].testDir` would recover this repo's full suite and its
230
+ higher score.
231
+
232
+ ### Corpus
233
+ Grown from 50 (2.0.0) to 100 public suites: 34 added in an interim,
234
+ unpublished pass (84 total — see VALIDATION.md for the full accounting of
235
+ that pass), then 16 more here for brand
236
+ recognition (Nextcloud, Ghost, Pinterest, Microsoft, The Guardian,
237
+ Shopify ×2, Adobe, Automattic, BBC, Google, Datadog, Twilio) plus three
238
+ named competitors (Checkly, Currents, LambdaTest), scored under the
239
+ exact same rules as everyone else. Every one of the 100 entries in
240
+ VALIDATION.md now carries the exact commit SHA scored and the date it
241
+ was tested, not just an aggregate "last run" date.
6
242
 
7
243
  ## 2.0.0 — 2026-09-23
8
244
 
@@ -37,7 +273,7 @@ own official examples must still score >= 90/A (see
37
273
  `tests/official-examples.test.ts`, vendored from microsoft/playwright),
38
274
  and a hand-written "gold" fixture must still reach exactly 100.
39
275
 
40
- ### Added — four rules narrowing the QAG-196 gameability gap (scored under `standard`)
276
+ ### Added — four rules narrowing the gameability gap (scored under `standard`)
41
277
  Narrowing, not closing: each rule matches specific known patterns (see the
42
278
  respellings each one closed in this same release), not the general class
43
279
  of "code that fakes stability or coverage" — a determined author can
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/qa-guardian/playwright-score/actions/workflows/ci.yml/badge.svg)](https://github.com/qa-guardian/playwright-score/actions/workflows/ci.yml)
4
4
  [![npm version](https://img.shields.io/npm/v/@qaguardian/playwright-score.svg)](https://www.npmjs.com/package/@qaguardian/playwright-score)
5
- [![license](https://img.shields.io/npm/l/@qaguardian/playwright-score.svg)](./LICENSE)
5
+ [![license](https://img.shields.io/npm/l/@qaguardian/playwright-score.svg)](https://github.com/qa-guardian/playwright-score/blob/main/LICENSE)
6
6
 
7
7
  **Deterministic, AI-free quality score for Playwright specs** — the
8
8
  weighted share of your tests that are clean.
@@ -19,76 +19,63 @@ The score never calls an LLM. AI may *generate* or *repair* code using findings;
19
19
 
20
20
  ## Real-world results
21
21
 
22
- 50 public Playwright suites, scored with the published package against each
22
+ 100 public Playwright suites, scored with the published package against each
23
23
  project's actual source — chosen to include both well-known,
24
- heavily-engineered platforms and smaller, less mature projects. Not curated
25
- to look good: 16 of 50 fail the default threshold, for real, verifiable
26
- reasons (see [VALIDATION.md](./VALIDATION.md), which also has the full
27
- before/after against the last published version).
24
+ heavily-engineered platforms and smaller, less mature projects, plus three
25
+ named testing-infra competitors scored under the exact same rules as
26
+ everyone else. Not curated to look good: 35 of 100 fail the default
27
+ threshold, for real, verifiable reasons (see
28
+ [VALIDATION.md](https://github.com/qa-guardian/playwright-score/blob/main/VALIDATION.md),
29
+ which also has the full before/after against the last published version
30
+ and every one of the 100 entries, not just the 15 shown below).
28
31
 
29
- **50 suites · 4,159 files · 20,183 tests · 34/50 passed (80% threshold)**
32
+ **100 suites · 7,281 files · 39,059 tests · 65/100 pass (80% threshold) ·
33
+ tested on 2026-09-24**
34
+
35
+ Top scorers (everyone at 97+; the next tier down is a 6-way tie at 96 — see
36
+ VALIDATION.md for the full 100):
37
+
38
+ | Repo (source scanned) | Score | Grade | Result |
39
+ |---|---:|:-:|:-:|
40
+ | [pinterest/gestalt](https://github.com/pinterest/gestalt/tree/22874a7522d1/playwright/accessibility) | 100/100 | A | PASS |
41
+ | [TryGhost/Ghost](https://github.com/TryGhost/Ghost/tree/9d7248b56d53/e2e/tests) | 99/100 | A | PASS |
42
+ | [hashicorp/vault](https://github.com/hashicorp/vault/tree/f03da019aba5/ui/e2e) | 98/100 | A | PASS |
43
+ | [microsoft/playwright (TodoMVC example)](https://github.com/microsoft/playwright/tree/7563c5a67eab/examples/todomvc) | 98/100 | A | PASS |
44
+ | [plausible/analytics](https://github.com/plausible/analytics/tree/8f3970104aa7/e2e) | 98/100 | A | PASS |
45
+ | [segmentio/analytics-next](https://github.com/segmentio/analytics-next/tree/2d6d55301804/packages/browser-integration-tests/src) | 98/100 | A | PASS |
46
+ | [cloudflare/templates](https://github.com/cloudflare/templates/tree/a0bb6ef9a990/playwright-tests) | 97/100 | A | PASS |
47
+ | [glpi-project/glpi](https://github.com/glpi-project/glpi/tree/9e8a9570a90b/tests/e2e/specs) | 97/100 | A | PASS |
48
+ | [shopify/hydrogen](https://github.com/shopify/hydrogen/tree/3ef7ec23096a/e2e/specs) | 97/100 | A | PASS |
49
+
50
+ Bottom 5 scorers:
30
51
 
31
52
  | Repo (source scanned) | Score | Grade | Result |
32
53
  |---|---:|:-:|:-:|
33
- | [Plausible Analytics](https://github.com/plausible/analytics/tree/0f362bd84eaa/e2e) | 98/100 | A | PASS |
34
- | [Playwright (own TodoMVC example)](https://github.com/microsoft/playwright/tree/0facd9234452/examples/todomvc) | 98/100 | A | PASS |
35
- | [Penpot](https://github.com/penpot/penpot/tree/433f8774497a/frontend/playwright) | 96/100 | A | PASS |
36
- | [SigNoz](https://github.com/SigNoz/signoz/tree/64fff60d7e8c/tests/e2e) | 96/100 | A | PASS |
37
- | [Temporal UI](https://github.com/temporalio/ui/tree/ffee0375c167/tests) | 96/100 | A | PASS |
38
- | [dub](https://github.com/dubinc/dub/tree/bd69439da068/apps/web/playwright) | 96/100 | A | PASS |
39
- | [Keycloak (admin-ui)](https://github.com/keycloak/keycloak/tree/525b92e82211/js/apps/admin-ui/test) | 95/100 | A | PASS |
40
- | [Storybook](https://github.com/storybookjs/storybook/tree/0365105152a6/code/e2e-internal) | 95/100 | A | PASS |
41
- | [Strapi](https://github.com/strapi/strapi/tree/142d9c6b31f3/tests/e2e) | 95/100 | A | PASS |
42
- | [freeCodeCamp](https://github.com/freeCodeCamp/freeCodeCamp/tree/42e6beeed4e9/e2e) | 94/100 | A | PASS |
43
- | [n8n](https://github.com/n8n-io/n8n/tree/4e3a7720cf85/packages/testing/playwright) | 94/100 | A | PASS |
44
- | [Documenso](https://github.com/documenso/documenso/tree/c81bc72c4c27/packages/app-tests) | 93/100 | A | PASS |
45
- | [WordPress Gutenberg](https://github.com/WordPress/gutenberg/tree/736cfd3daf75/test/e2e) | 93/100 | A | PASS |
46
- | [Supabase](https://github.com/supabase/supabase/tree/e1bdcc99dbf8/e2e) | 92/100 | A | PASS |
47
- | [Umami](https://github.com/umami-software/umami/tree/ec0ff50388c2/tests/e2e) | 92/100 | A | PASS |
48
- | [Coder](https://github.com/coder/coder/tree/47d3e1c52b14/site/e2e) | 91/100 | A | PASS |
49
- | [Twenty CRM](https://github.com/twentyhq/twenty/tree/710f1632ae96/packages/twenty-e2e-testing/tests) | 91/100 | A | PASS |
50
- | [Vendure](https://github.com/vendure-ecommerce/vendure/tree/07d595e1b844/packages/dashboard/e2e) | 91/100 | A | PASS |
51
- | [Wire (wire-webapp)](https://github.com/wireapp/wire-webapp/tree/9b0f1ea71e1d/apps/webapp/test/e2e_tests) | 91/100 | A | PASS |
52
- | [WooCommerce](https://github.com/woocommerce/woocommerce/tree/aa5308378124/plugins/woocommerce/tests/e2e) | 90/100 | A | PASS |
53
- | [Element (Matrix client)](https://github.com/element-hq/element-web/tree/3153cc6ae123/apps/web/playwright) | 89/100 | B | PASS |
54
- | [Clerk](https://github.com/clerk/javascript/tree/81a641348101/integration/tests) | 87/100 | B | PASS |
55
- | [Grafana](https://github.com/grafana/grafana/tree/1fd1d6398053/e2e-playwright) | 87/100 | B | PASS |
56
- | [PostHog](https://github.com/PostHog/posthog/tree/854a4262ec8b/playwright) | 87/100 | B | PASS |
57
- | [Formbricks](https://github.com/formbricks/formbricks/tree/55ade3bc2a5a/apps/web/playwright) | 85/100 | B | PASS |
58
- | [Bruno](https://github.com/usebruno/bruno/tree/f1b433f7b06f/tests) | 84/100 | B | PASS |
59
- | [Mattermost](https://github.com/mattermost/mattermost/tree/f0b63f8ed11e/e2e-tests/playwright) | 84/100 | B | PASS |
60
- | [Saleor Dashboard](https://github.com/saleor/saleor-dashboard/tree/3508127f50dc/playwright) | 84/100 | B | PASS |
61
- | [WordPress.com Calypso](https://github.com/Automattic/wp-calypso/tree/af6245bcd266/test/e2e) | 84/100 | B | PASS |
62
- | [sencho](https://github.com/Studio-Saelix/sencho/tree/489e1a9a9a90/e2e) | 84/100 | B | PASS |
63
- | [Handsontable](https://github.com/handsontable/handsontable/tree/40c2738b2e87/tests/e2e) | 83/100 | B | PASS |
64
- | [Rocket.Chat](https://github.com/RocketChat/Rocket.Chat/tree/828fa06f72d5/apps/meteor/tests/e2e) | 83/100 | B | PASS |
65
- | [novu](https://github.com/novuhq/novu/tree/6fc9d02ea001/apps/dashboard/tests) | 82/100 | B | PASS |
66
- | [Appsmith](https://github.com/appsmithorg/appsmith/tree/a72a95b73a99/app/client/playwright/tests) | 80/100 | B | PASS |
67
- | [Payload CMS](https://github.com/payloadcms/payload/tree/5f9bcac2f07b/test/admin/e2e) | 79/100 | C | FAIL |
68
- | [Immich](https://github.com/immich-app/immich/tree/f48d4b332127/e2e/src/specs) | 78/100 | C | FAIL |
69
- | [VS Code (component fixtures)](https://github.com/microsoft/vscode/tree/7b24b6303075/test/componentFixtures/playwright) | 77/100 | C | FAIL |
70
- | [Astro](https://github.com/withastro/astro/tree/40896acb7449/packages/astro/e2e) | 75/100 | C | FAIL |
71
- | [AFFiNE](https://github.com/toeverything/AFFiNE/tree/d897bb3d8409/tests/blocksuite/e2e) | 72/100 | C | FAIL |
72
- | [Builder.io SDKs](https://github.com/BuilderIO/builder/tree/51cd0521bbf1/packages/sdks-tests/src/e2e-tests) | 72/100 | C | FAIL |
73
- | [openplayerjs](https://github.com/openplayerjs/openplayerjs/tree/ee1673106237/e2e) | 72/100 | C | FAIL |
74
- | [tldraw](https://github.com/tldraw/tldraw/tree/a6cfe996e1df/apps/examples/e2e) | 72/100 | C | FAIL |
75
- | [cal.com](https://github.com/calcom/cal.com/tree/54343aa685ae/apps/web/playwright) | 71/100 | C | FAIL |
76
- | [Qwik](https://github.com/QwikDev/qwik/tree/02b5c6b18fe7/e2e) | 70/100 | C | FAIL |
77
- | [Wekan](https://github.com/wekan/wekan/tree/84414711b7ae/tests/playwright/specs) | 70/100 | C | FAIL |
78
- | [livecodes](https://github.com/live-codes/livecodes/tree/747054828256/e2e) | 70/100 | C | FAIL |
79
- | [OpenReplay](https://github.com/openreplay/openreplay/tree/3fce37d89113/frontend/tests/playwright) | 66/100 | D | FAIL |
80
- | [Flagsmith](https://github.com/Flagsmith/flagsmith/tree/66dc2e9ed00b/frontend/e2e/tests) | 62/100 | D | FAIL |
81
- | [Activepieces](https://github.com/activepieces/activepieces/tree/ab8cd5c9069e/packages/tests-e2e) | 58/100 | F | FAIL |
82
- | [TheCyberHub](https://github.com/th3cyb3rhub/TheCyberHub/tree/1cf418654f16/e2e) | 56/100 | F | FAIL |
83
-
84
- Repo names link to the exact source scanned. Full methodology, findings
85
- breakdown, and `scripts/validate-corpus.sh` to reproduce every number here
86
- yourself live in [VALIDATION.md](./VALIDATION.md). A self-contained visual
54
+ | [Flagsmith/flagsmith](https://github.com/Flagsmith/flagsmith/tree/ea5ad826e85b/frontend/e2e/tests) | 62/100 | D | FAIL |
55
+ | [activepieces/activepieces](https://github.com/activepieces/activepieces/tree/9e493a87f471/packages/tests-e2e) | 58/100 | F | FAIL |
56
+ | [th3cyb3rhub/TheCyberHub](https://github.com/th3cyb3rhub/TheCyberHub/tree/1cf418654f16/e2e) | 56/100 | F | FAIL |
57
+ | [DataDog/documentation](https://github.com/DataDog/documentation/tree/6cd10f40cf46/hugo/e2e) | 50/100 | F | FAIL |
58
+ | [getsentry/spotlight](https://github.com/getsentry/spotlight/tree/3dd5eb5b315f/packages/spotlight/tests) | 33/100 | F | FAIL |
59
+
60
+ Repo names link to the exact commit scanned. Selected by a mix of
61
+ mechanical GitHub search (real `@playwright/test` usage, hand-verified)
62
+ and, for the most recent 16, hand-picked brand recognition — three of the
63
+ 100 are named competitors' own example suites (Checkly, Currents,
64
+ LambdaTest), scored with no separate bar. Full methodology, the complete
65
+ 100-repo table, findings breakdown, and
66
+ `scripts/validate-corpus.sh` to reproduce every number here yourself live
67
+ in
68
+ [VALIDATION.md](https://github.com/qa-guardian/playwright-score/blob/main/VALIDATION.md).
69
+ A machine-readable
70
+ [`validation.json`](https://github.com/qa-guardian/playwright-score/blob/main/validation.json)
71
+ carries the same per-repo data for tooling. See
72
+ [CHANGELOG.md](https://github.com/qa-guardian/playwright-score/blob/main/CHANGELOG.md)
73
+ for what changed release to release. A self-contained visual
87
74
  report of an earlier five-suite audit lives at
88
- [`docs/scorecard.html`](./docs/scorecard.html) — host it wherever
75
+ [`docs/scorecard.html`](https://github.com/qa-guardian/playwright-score/blob/main/docs/scorecard.html) — host it wherever
89
76
  (GitHub Pages, qaguardian.com, ...).
90
77
 
91
- See [METHODOLOGY.md](./METHODOLOGY.md) for the full scoring model, or the product write-up on the [landing page](https://qaguardian.com/open-source/playwright-score).
78
+ See [METHODOLOGY.md](https://github.com/qa-guardian/playwright-score/blob/main/METHODOLOGY.md) for the full scoring model, or the product write-up on the [landing page](https://qaguardian.com/open-source/playwright-score).
92
79
 
93
80
  ## Install
94
81
 
@@ -9,4 +9,6 @@ export declare function runEslint(options: {
9
9
  cwd?: string;
10
10
  /** See findLocalAssertionHelperNames in metrics.ts. */
11
11
  assertFunctionNames?: string[];
12
+ /** See detectExtendedTestAliases in test-aliases.ts. */
13
+ testAliasNames?: string[];
12
14
  }): Promise<Finding[]>;
@@ -4,10 +4,28 @@ import { commonAncestorDir } from './fs-util.js';
4
4
  import playwright from 'eslint-plugin-playwright';
5
5
  import { basePlugin } from './rules/base-plugin.js';
6
6
  import { mapRule } from './profiles.js';
7
- function buildConfig(assertFunctionNames) {
7
+ function buildConfig(assertFunctionNames, testAliasNames) {
8
8
  const base = [
9
9
  {
10
10
  files: ['**/*.{ts,tsx,js,jsx,mjs,cjs}'],
11
+ // Identifiers bound to a `test.extend(...)`-derived fixture under a
12
+ // custom name (test-aliases.ts) — upstream eslint-plugin-playwright
13
+ // already dereferences a *same-file* extend chain internally, but
14
+ // can't follow one across files (its own `dereference` is scoped to
15
+ // the current file); this setting is what makes a cross-file alias
16
+ // (`import { loggedTest } from './fixtures'`) resolve to `test` for
17
+ // every rule that keys off the literal identifier — no-standalone-
18
+ // expect, no-conditional-expect, no-conditional-in-test, valid-expect,
19
+ // no-focused-test, and others built on the same parseFnCall/
20
+ // resolveToPlaywrightFn helper. Verified against two real corpus
21
+ // repos (argos-ci/argos's `loggedTest`, apache/superset's
22
+ // `testWithAssets`) where this exact cross-file shape was the actual
23
+ // false-positive cause.
24
+ settings: {
25
+ playwright: {
26
+ globalAliases: { test: testAliasNames },
27
+ },
28
+ },
11
29
  // A pre-2.0.0 review found `/* eslint-disable */` on line 1 of a
12
30
  // 10-test file (waitForTimeout + expect(true).toBe(true) in every
13
31
  // test) took it from 35/F to 99/A — every rule this package enables
@@ -83,7 +101,7 @@ function buildConfig(assertFunctionNames) {
83
101
  // with the same severity for the actual anti-pattern (an
84
102
  // always-skipped test declaration) only.
85
103
  'playwright/no-skipped-test': 'off',
86
- 'pwscore/no-skipped-test-declaration': 'warn',
104
+ 'pwscore/no-skipped-test-declaration': ['warn', { testAliasNames }],
87
105
  'playwright/no-raw-locators': 'warn',
88
106
  // Both were mapped in profiles.ts (report-only, so they don't
89
107
  // double-penalize the ratio-based locators dimension) but never
@@ -135,7 +153,7 @@ function buildConfig(assertFunctionNames) {
135
153
  'pwscore/no-timer-sleep': 'error',
136
154
  'pwscore/no-coordinate-click': 'warn',
137
155
  'pwscore/no-trivial-assertion': 'error',
138
- 'pwscore/no-soft-assertion-only-test': 'warn',
156
+ 'pwscore/no-soft-assertion-only-test': ['warn', { testAliasNames }],
139
157
  },
140
158
  },
141
159
  ];
@@ -166,7 +184,7 @@ export async function runEslint(options) {
166
184
  const cwd = options.files.length > 0
167
185
  ? commonAncestorDir(options.files)
168
186
  : (options.cwd ?? process.cwd());
169
- const overrideConfig = buildConfig(options.assertFunctionNames ?? []);
187
+ const overrideConfig = buildConfig(options.assertFunctionNames ?? [], options.testAliasNames ?? []);
170
188
  // Try to attach typescript-eslint parser for .ts files
171
189
  try {
172
190
  const tsParser = await import('@typescript-eslint/parser');
@@ -32,5 +32,13 @@ export function formatMarkdown(result) {
32
32
  lines.push('');
33
33
  lines.push('</details>');
34
34
  }
35
+ if (result.configWarnings?.length) {
36
+ lines.push('');
37
+ lines.push('## Config warnings');
38
+ lines.push('');
39
+ for (const w of result.configWarnings) {
40
+ lines.push(`- ${w}`);
41
+ }
42
+ }
35
43
  return lines.join('\n');
36
44
  }
@@ -36,6 +36,13 @@ export function formatText(result) {
36
36
  lines.push(` … and ${result.skippedFiles.length - 20} more`);
37
37
  }
38
38
  }
39
+ if (result.configWarnings?.length) {
40
+ lines.push('');
41
+ lines.push('Config warnings:');
42
+ for (const w of result.configWarnings) {
43
+ lines.push(` ${w}`);
44
+ }
45
+ }
39
46
  return lines.join('\n');
40
47
  }
41
48
  function pathBasename(p) {
@@ -1,3 +1,4 @@
1
+ export declare function resolveRelativeImport(fromFile: string, specifier: string, boundaryDir: string): string | undefined;
1
2
  /**
2
3
  * A Page Object Model is common in real Playwright suites: spec files
3
4
  * interact with the page entirely through methods on an imported class
@@ -35,7 +35,7 @@ function extractRelativeImportSpecifiers(source, file) {
35
35
  }
36
36
  return specifiers;
37
37
  }
38
- function resolveRelativeImport(fromFile, specifier, boundaryDir) {
38
+ export function resolveRelativeImport(fromFile, specifier, boundaryDir) {
39
39
  const base = path.resolve(path.dirname(fromFile), specifier);
40
40
  const candidates = [];
41
41
  for (const ext of RESOLVE_EXTENSIONS)