From 1bf812f693a35ccf45be18329982b28765934845 Mon Sep 17 00:00:00 2001 From: vCaisim Date: Thu, 22 Oct 2026 14:14:34 +0300 Subject: [PATCH 1/8] fix(cmd): replace globby with tinyglobby to drop braces Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 278 ++---------------- packages/cmd/package.json | 2 +- packages/cmd/src/commands/convert/options.ts | 4 +- packages/cmd/src/lib/__tests__/glob.test.ts | 147 +++++++++ packages/cmd/src/lib/glob.ts | 66 +++++ .../src/services/cache/__tests__/path.spec.ts | 21 +- packages/cmd/src/services/cache/path.ts | 6 +- 7 files changed, 260 insertions(+), 264 deletions(-) create mode 100644 packages/cmd/src/lib/__tests__/glob.test.ts create mode 100644 packages/cmd/src/lib/glob.ts diff --git a/package-lock.json b/package-lock.json index 8d5ee4cc..a6bac6e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4066,41 +4066,6 @@ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@octokit/auth-token": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", @@ -6311,15 +6276,6 @@ "url": "https://ko-fi.com/dangreen" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -6698,18 +6654,6 @@ "node": "20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.28.9", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", @@ -7613,18 +7557,6 @@ "resolved": "examples/detox", "link": true }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/dotenv": { "version": "16.0.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", @@ -8359,34 +8291,6 @@ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -8428,15 +8332,6 @@ "fast-string-width": "^3.0.2" } }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -8461,7 +8356,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -8503,18 +8397,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -8900,26 +8782,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9102,6 +8964,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -9220,6 +9083,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9248,6 +9112,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -9301,15 +9166,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -10868,40 +10724,6 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -11566,15 +11388,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -11599,7 +11412,6 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -12002,26 +11814,6 @@ ], "license": "MIT" }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/quickjs-wasi": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz", @@ -12348,16 +12140,6 @@ "node": ">= 4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rimraf": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", @@ -12515,29 +12297,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -12651,6 +12410,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13206,18 +12966,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -14217,7 +13965,6 @@ "execa": "^9.5.1", "fs-extra": "^11.3.4", "getos": "^3.2.1", - "globby": "^11.1.0", "https-proxy-agent": "^7.0.4", "lodash": "^4.18.1", "nanoid": "^3.3.8", @@ -14225,6 +13972,7 @@ "proxy-from-env": "^1.1.0", "semver": "^7.8.5", "source-map-support": "^0.5.21", + "tinyglobby": "^0.2.17", "tmp": "^0.2.7", "tmp-promise": "^3.0.3", "ts-pattern": "^5.8.0", @@ -14297,6 +14045,22 @@ "url": "https://dotenvx.com" } }, + "packages/cmd/node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "packages/jest": { "name": "@currents/jest", "version": "1.5.1", diff --git a/packages/cmd/package.json b/packages/cmd/package.json index 03627b5b..68758b31 100644 --- a/packages/cmd/package.json +++ b/packages/cmd/package.json @@ -67,7 +67,6 @@ "execa": "^9.5.1", "fs-extra": "^11.3.4", "getos": "^3.2.1", - "globby": "^11.1.0", "https-proxy-agent": "^7.0.4", "lodash": "^4.18.1", "nanoid": "^3.3.8", @@ -75,6 +74,7 @@ "proxy-from-env": "^1.1.0", "semver": "^7.8.5", "source-map-support": "^0.5.21", + "tinyglobby": "^0.2.17", "tmp": "^0.2.7", "tmp-promise": "^3.0.3", "ts-pattern": "^5.8.0", diff --git a/packages/cmd/src/commands/convert/options.ts b/packages/cmd/src/commands/convert/options.ts index 394d7599..ffb0aa52 100644 --- a/packages/cmd/src/commands/convert/options.ts +++ b/packages/cmd/src/commands/convert/options.ts @@ -1,5 +1,5 @@ import { InvalidArgumentError, Option } from '@commander-js/extra-typings'; -import * as globby from 'globby'; +import { globPaths } from '@lib/glob'; export enum REPORT_INPUT_FORMATS { junit = 'junit', @@ -44,7 +44,7 @@ export const frameworkVersionOption = new Option( function validateGlobPattern(value: string) { const patterns = value.split(',').map((pattern) => pattern.trim()); - const allResults = globby.sync(patterns); + const allResults = globPaths(patterns); if (allResults.length === 0) { throw new InvalidArgumentError('No files found with the provided patterns'); diff --git a/packages/cmd/src/lib/__tests__/glob.test.ts b/packages/cmd/src/lib/__tests__/glob.test.ts new file mode 100644 index 00000000..bfd9ff4f --- /dev/null +++ b/packages/cmd/src/lib/__tests__/glob.test.ts @@ -0,0 +1,147 @@ +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { globPaths } from '../glob'; + +describe('globPaths', () => { + let root: string; + let cwd: string; + + beforeEach(() => { + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'glob-'))); + cwd = path.join(root, 'project'); + for (const file of [ + 'reports/a.xml', + 'reports/b.xml', + 'reports/c.json', + 'reports/skip.xml', + 'reports/.hidden.xml', + 'reports/nested/d.xml', + 'dist/a.js', + 'dist/sub/b.js', + ]) { + fs.outputFileSync(path.join(cwd, file), file); + } + fs.outputFileSync(path.join(root, 'outside/r.xml'), 'r'); + vi.spyOn(process, 'cwd').mockReturnValue(cwd); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.removeSync(root); + }); + + const sorted = (paths: string[]) => [...paths].sort(); + + it('returns the matches of relative patterns relative to the current folder', () => { + expect(sorted(globPaths(['reports/**/*.xml']))).toEqual([ + 'reports/a.xml', + 'reports/b.xml', + 'reports/nested/d.xml', + 'reports/skip.xml', + ]); + }); + + it('expands brace lists and ranges', () => { + expect(sorted(globPaths(['reports/{a,b}.xml']))).toEqual([ + 'reports/a.xml', + 'reports/b.xml', + ]); + expect(sorted(globPaths(['reports/*.{xml,json}']))).toHaveLength(4); + }); + + it('returns the files of a folder', () => { + expect(sorted(globPaths(['dist']))).toEqual(['dist/a.js', 'dist/sub/b.js']); + }); + + it('matches a dotfile only by its name', () => { + expect(globPaths(['reports/*.xml'])).not.toContain('reports/.hidden.xml'); + expect(globPaths(['reports/.hidden.xml'])).toEqual(['reports/.hidden.xml']); + }); + + it('returns the matches of absolute patterns as absolute paths', () => { + expect(sorted(globPaths([path.join(cwd, 'dist')]))).toEqual([ + path.join(cwd, 'dist/a.js'), + path.join(cwd, 'dist/sub/b.js'), + ]); + expect(globPaths([path.join(root, 'outside/*.xml')])).toEqual([ + path.join(root, 'outside/r.xml'), + ]); + }); + + it('keeps the form of each pattern when relative and absolute are mixed', () => { + expect( + sorted(globPaths(['reports/a.xml', path.join(cwd, 'reports/b.xml')])) + ).toEqual([path.join(cwd, 'reports/b.xml'), 'reports/a.xml']); + }); + + it('leaves out the matches of negated patterns', () => { + expect(sorted(globPaths(['reports/*.xml', '!reports/skip.xml']))).toEqual([ + 'reports/a.xml', + 'reports/b.xml', + ]); + expect( + sorted( + globPaths([ + path.join(cwd, 'reports/*.xml'), + '!' + path.join(cwd, 'reports/skip.xml'), + ]) + ) + ).toEqual([ + path.join(cwd, 'reports/a.xml'), + path.join(cwd, 'reports/b.xml'), + ]); + }); + + it('leaves out every file of a negated folder', () => { + expect(globPaths(['reports/**/*.xml', '!reports/nested'])).not.toContain( + 'reports/nested/d.xml' + ); + }); + + // The cases below are how globby 11 behaved + it('keeps the files of a folder whose name matches a negated pattern', () => { + expect(globPaths(['reports/**/*.xml', '!reports/**/n*'])).toContain( + 'reports/nested/d.xml' + ); + }); + + it('applies a negated pattern only to the patterns before it', () => { + expect(globPaths(['!reports/skip.xml', 'reports/*.xml'])).toContain( + 'reports/skip.xml' + ); + }); + + it('leaves nothing out of an absolute pattern with a relative negation', () => { + expect( + globPaths([path.join(cwd, 'reports/*.xml'), '!reports/skip.xml']) + ).toContain(path.join(cwd, 'reports/skip.xml')); + }); + + // convert keeps the last report of a spec, so the order decides the results + it('returns the matches in pattern order, shallower files first', () => { + expect(globPaths(['reports/**/*.xml'])).toEqual([ + 'reports/a.xml', + 'reports/b.xml', + 'reports/skip.xml', + 'reports/nested/d.xml', + ]); + expect(globPaths(['reports/skip.xml', 'reports/*.xml'])).toEqual([ + 'reports/skip.xml', + 'reports/a.xml', + 'reports/b.xml', + ]); + }); + + it('returns nothing for a pattern that matches no file', () => { + expect(globPaths(['nope/*.xml'])).toEqual([]); + }); + + // GHSA-vfj7-8cjw-p6xm: braces overflowed the stack on deeply nested groups. + it('handles deeply nested braces', () => { + const depth = 5000; + const pattern = '{'.repeat(depth) + 'a' + '}'.repeat(depth) + '.xml'; + expect(globPaths([pattern])).toEqual([]); + }); +}); diff --git a/packages/cmd/src/lib/glob.ts b/packages/cmd/src/lib/glob.ts new file mode 100644 index 00000000..9a029b65 --- /dev/null +++ b/packages/cmd/src/lib/glob.ts @@ -0,0 +1,66 @@ +import { isAbsolute, resolve } from 'path'; +import { globSync } from 'tinyglobby'; + +/** + * Returns the files that match the glob patterns, the way globby 11 returned + * them, so that switching from globby changes no command's results: + * - a match of an absolute pattern is an absolute path, and a match of a + * relative pattern is relative to the current folder. tinyglobby alone + * returns every match relative to the current folder, so + * `--input-file /tmp/r.xml` would print as `../../tmp/r.xml`. + * - a `!pattern` leaves out the files whose path matches it, or every file of + * a folder it names, from the patterns before it, and a relative `!pattern` + * leaves nothing out of an absolute pattern's matches. tinyglobby's `ignore` + * skips whole folders whose name matches, so `!**\/a*` would also drop + * `a_/report.xml`. + * - the matches of each pattern come in the order of the patterns, shallower + * files first. When two reports hold the same spec, `convert` keeps the one + * it reads last, so a different order changes the converted results. + */ +export function globPaths(patterns: string[]): string[] { + const matches = new Set(); + + patterns.forEach((pattern, index) => { + if (pattern.startsWith('!')) { + return; + } + const left = new Set( + patterns + .slice(index + 1) + .filter((p) => p.startsWith('!')) + .map((p) => p.slice(1)) + // globby matched a relative !pattern against the absolute paths an + // absolute pattern returns, so it never left anything out of them + .filter((p) => !isAbsolute(pattern) || isAbsolute(p)) + .flatMap((p) => globSync(p, { absolute: true })) + ); + const files = globSync(pattern, { absolute: isAbsolute(pattern) }).filter( + (file) => !left.has(resolve(file).replace(/\\/g, '/')) + ); + for (const file of sortByDepth(files)) { + matches.add(file); + } + }); + return [...matches]; +} + +// globby listed each folder in the order the file system returns it: by +// character code on macOS and Linux, ignoring case on Windows. Comparing whole +// paths instead of one folder name at a time would put a-b/x.xml before +// a/x.xml. +function sortByDepth(files: string[]) { + const sortable = files.map((file) => ({ + file, + segments: (process.platform === 'win32' ? file.toUpperCase() : file).split( + '/' + ), + })); + sortable.sort(({ segments: sa }, { segments: sb }) => { + if (sa.length !== sb.length) { + return sa.length - sb.length; + } + const i = sa.findIndex((segment, index) => segment !== sb[index]); + return i === -1 ? 0 : sa[i] < sb[i] ? -1 : 1; + }); + return sortable.map(({ file }) => file); +} diff --git a/packages/cmd/src/services/cache/__tests__/path.spec.ts b/packages/cmd/src/services/cache/__tests__/path.spec.ts index ced50f09..d62fc1ce 100644 --- a/packages/cmd/src/services/cache/__tests__/path.spec.ts +++ b/packages/cmd/src/services/cache/__tests__/path.spec.ts @@ -2,7 +2,7 @@ import fs from 'fs-extra'; import os from 'os'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { filterPaths, getUploadPaths } from '../path'; +import { filterPaths, getLastRunFilePaths, getUploadPaths } from '../path'; vi.mock('../lib', () => ({ warn: vi.fn() })); @@ -35,6 +35,25 @@ describe('cache paths', () => { expect(paths).toEqual([path.join(cwd, 'dist/a.js')]); }); + it('finds every .last-run.json under the Playwright output folder', async () => { + fs.outputFileSync(path.join(cwd, 'test-results/.last-run.json'), '{}'); + fs.outputFileSync(path.join(cwd, 'test-results/s1/.last-run.json'), '{}'); + fs.outputFileSync(path.join(cwd, 'test-results/s1/trace.zip'), 'zip'); + + expect((await getLastRunFilePaths()).sort()).toEqual([ + path.join(cwd, 'test-results/.last-run.json'), + path.join(cwd, 'test-results/s1/.last-run.json'), + ]); + }); + + it('finds .last-run.json in an output folder outside the current folder', async () => { + fs.outputFileSync(path.join(root, 'pw-out/.last-run.json'), '{}'); + + expect(await getLastRunFilePaths(path.join(root, 'pw-out'))).toEqual([ + path.join(root, 'pw-out/.last-run.json'), + ]); + }); + it.each(['../outside.txt', '/etc/hosts', 'dist/../../outside.txt', '..'])( 'leaves out %s', (filePath) => { diff --git a/packages/cmd/src/services/cache/path.ts b/packages/cmd/src/services/cache/path.ts index 3a191098..0780e9be 100644 --- a/packages/cmd/src/services/cache/path.ts +++ b/packages/cmd/src/services/cache/path.ts @@ -1,5 +1,5 @@ import path from 'path'; -import * as globby from 'globby'; +import { globPaths } from '@lib/glob'; import { MetaFile, warn } from './lib'; export const getLastRunFilePaths = async (outputPath?: string) => { @@ -10,7 +10,7 @@ export const getLastRunFilePaths = async (outputPath?: string) => { path.posix.join(prefix.replace(/\\/g, '/'), '.last-run.json'), ]; - return globby.sync(patterns); + return globPaths(patterns); }; export const getUploadPaths = async (pathPatterns: string[] = []) => { @@ -18,7 +18,7 @@ export const getUploadPaths = async (pathPatterns: string[] = []) => { if (insidePaths.length === 0) { return []; } - return globby.sync(insidePaths.map((p) => p.replace(/\\/g, '/'))); + return globPaths(insidePaths.map((p) => p.replace(/\\/g, '/'))); }; /** Keeps the paths inside the current folder, with a warning for the others. */ From d7fde4437f7cb300e9f86f98eac4a157758f8bb6 Mon Sep 17 00:00:00 2001 From: vCaisim Date: Thu, 22 Oct 2026 14:45:38 +0300 Subject: [PATCH 2/8] fix(cmd): match glob negations against the files found Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 9 ++++ packages/cmd/package.json | 2 + packages/cmd/src/lib/__tests__/glob.test.ts | 35 +++++++++++++ packages/cmd/src/lib/glob.ts | 56 +++++++++++++++------ 4 files changed, 88 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index a6bac6e0..738dc8d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5409,6 +5409,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/proxy-from-env": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@types/proxy-from-env/-/proxy-from-env-1.0.4.tgz", @@ -13968,6 +13975,7 @@ "https-proxy-agent": "^7.0.4", "lodash": "^4.18.1", "nanoid": "^3.3.8", + "picomatch": "^4.0.7", "pretty-ms": "^7.0.1", "proxy-from-env": "^1.1.0", "semver": "^7.8.5", @@ -13996,6 +14004,7 @@ "@types/getos": "^3.0.4", "@types/lodash": "^4.17.16", "@types/node": "^25.5.0", + "@types/picomatch": "^4.0.3", "@types/proxy-from-env": "^1.0.4", "@types/semver": "^7.5.8", "@types/tmp": "^0.2.6", diff --git a/packages/cmd/package.json b/packages/cmd/package.json index 68758b31..6f55610d 100644 --- a/packages/cmd/package.json +++ b/packages/cmd/package.json @@ -37,6 +37,7 @@ "@types/getos": "^3.0.4", "@types/lodash": "^4.17.16", "@types/node": "^25.5.0", + "@types/picomatch": "^4.0.3", "@types/proxy-from-env": "^1.0.4", "@types/semver": "^7.5.8", "@types/tmp": "^0.2.6", @@ -70,6 +71,7 @@ "https-proxy-agent": "^7.0.4", "lodash": "^4.18.1", "nanoid": "^3.3.8", + "picomatch": "^4.0.7", "pretty-ms": "^7.0.1", "proxy-from-env": "^1.1.0", "semver": "^7.8.5", diff --git a/packages/cmd/src/lib/__tests__/glob.test.ts b/packages/cmd/src/lib/__tests__/glob.test.ts index bfd9ff4f..4c5bfd80 100644 --- a/packages/cmd/src/lib/__tests__/glob.test.ts +++ b/packages/cmd/src/lib/__tests__/glob.test.ts @@ -134,10 +134,45 @@ describe('globPaths', () => { ]); }); + it('sorts one folder name at a time', () => { + for (const file of ['order/a/x.xml', 'order/a-b/x.xml']) { + fs.outputFileSync(path.join(cwd, file), file); + } + + expect(globPaths(['order/**/*.xml'])).toEqual([ + 'order/a/x.xml', + 'order/a-b/x.xml', + ]); + }); + + it('sorts ignoring case on Windows, like its file system', () => { + for (const file of ['order/a.xml', 'order/UPPER.xml', 'order/z.xml']) { + fs.outputFileSync(path.join(cwd, file), file); + } + expect(globPaths(['order/*.xml'])).toEqual([ + 'order/UPPER.xml', + 'order/a.xml', + 'order/z.xml', + ]); + + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + expect(globPaths(['order/*.xml'])).toEqual([ + 'order/a.xml', + 'order/UPPER.xml', + 'order/z.xml', + ]); + }); + it('returns nothing for a pattern that matches no file', () => { expect(globPaths(['nope/*.xml'])).toEqual([]); }); + // globby 11 matched every file in the current folder for an empty pattern, + // so a trailing comma in --input-file made convert read all of them + it('matches nothing for an empty pattern', () => { + expect(globPaths(['reports/a.xml', ''])).toEqual(['reports/a.xml']); + }); + // GHSA-vfj7-8cjw-p6xm: braces overflowed the stack on deeply nested groups. it('handles deeply nested braces', () => { const depth = 5000; diff --git a/packages/cmd/src/lib/glob.ts b/packages/cmd/src/lib/glob.ts index 9a029b65..edcb474e 100644 --- a/packages/cmd/src/lib/glob.ts +++ b/packages/cmd/src/lib/glob.ts @@ -1,4 +1,6 @@ +import fs from 'fs'; import { isAbsolute, resolve } from 'path'; +import picomatch from 'picomatch'; import { globSync } from 'tinyglobby'; /** @@ -11,8 +13,8 @@ import { globSync } from 'tinyglobby'; * - a `!pattern` leaves out the files whose path matches it, or every file of * a folder it names, from the patterns before it, and a relative `!pattern` * leaves nothing out of an absolute pattern's matches. tinyglobby's `ignore` - * skips whole folders whose name matches, so `!**\/a*` would also drop - * `a_/report.xml`. + * skips whole folders whose name matches, so `!reports/a*` would also drop + * `reports/a_old/report.xml`. * - the matches of each pattern come in the order of the patterns, shallower * files first. When two reports hold the same spec, `convert` keeps the one * it reads last, so a different order changes the converted results. @@ -24,18 +26,10 @@ export function globPaths(patterns: string[]): string[] { if (pattern.startsWith('!')) { return; } - const left = new Set( - patterns - .slice(index + 1) - .filter((p) => p.startsWith('!')) - .map((p) => p.slice(1)) - // globby matched a relative !pattern against the absolute paths an - // absolute pattern returns, so it never left anything out of them - .filter((p) => !isAbsolute(pattern) || isAbsolute(p)) - .flatMap((p) => globSync(p, { absolute: true })) - ); - const files = globSync(pattern, { absolute: isAbsolute(pattern) }).filter( - (file) => !left.has(resolve(file).replace(/\\/g, '/')) + const absolute = isAbsolute(pattern); + const isLeftOut = negationMatcher(patterns.slice(index + 1), absolute); + const files = globSync(pattern, { absolute }).filter( + (file) => !isLeftOut(file) ); for (const file of sortByDepth(files)) { matches.add(file); @@ -44,6 +38,40 @@ export function globPaths(patterns: string[]): string[] { return [...matches]; } +// Matches the files a pattern found against the `!patterns` after it, instead +// of listing what each `!pattern` matches: `!**/old*` would otherwise walk the +// whole current folder, node_modules included. +function negationMatcher(patterns: string[], absolute: boolean) { + const matchers = patterns + .filter((pattern) => pattern.length > 1 && pattern.startsWith('!')) + .map((pattern) => pattern.slice(1).replace(/\\/g, '/')) + // globby matched a relative !pattern against the absolute paths an + // absolute pattern returns, so it never left anything out of them + .filter((pattern) => !absolute || isAbsolute(pattern)) + .map((pattern) => ({ + absolute: isAbsolute(pattern), + isMatch: picomatch( + isFolder(pattern) ? `${pattern.replace(/\/$/, '')}/**` : pattern + ), + })); + + return (file: string) => + matchers.some(({ absolute, isMatch }) => + isMatch(absolute ? resolve(file).replace(/\\/g, '/') : file) + ); +} + +function isFolder(pattern: string) { + if (picomatch.scan(pattern).isGlob) { + return false; + } + try { + return fs.statSync(resolve(pattern)).isDirectory(); + } catch { + return false; + } +} + // globby listed each folder in the order the file system returns it: by // character code on macOS and Linux, ignoring case on Windows. Comparing whole // paths instead of one folder name at a time would put a-b/x.xml before From 696e9a8e1a41b5604f19edac9898499e925f9764 Mon Sep 17 00:00:00 2001 From: vCaisim Date: Thu, 22 Oct 2026 14:57:33 +0300 Subject: [PATCH 3/8] refactor(cmd): compile glob negations once and run the glob tests on Windows paths Co-Authored-By: Claude Opus 5.5 --- packages/cmd/src/lib/__tests__/glob.test.ts | 166 ++++++++++-------- packages/cmd/src/lib/glob.ts | 72 ++++---- .../src/services/cache/__tests__/path.spec.ts | 9 +- 3 files changed, 128 insertions(+), 119 deletions(-) diff --git a/packages/cmd/src/lib/__tests__/glob.test.ts b/packages/cmd/src/lib/__tests__/glob.test.ts index 4c5bfd80..6b19ab6a 100644 --- a/packages/cmd/src/lib/__tests__/glob.test.ts +++ b/packages/cmd/src/lib/__tests__/glob.test.ts @@ -2,7 +2,11 @@ import fs from 'fs-extra'; import os from 'os'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { globPaths } from '../glob'; +import { globPaths, sortByDepth } from '../glob'; + +// Glob patterns use / on every platform, and globPaths returns / paths +const posix = (...parts: string[]) => path.join(...parts).replace(/\\/g, '/'); +const sorted = (paths: string[]) => [...paths].sort(); describe('globPaths', () => { let root: string; @@ -32,8 +36,6 @@ describe('globPaths', () => { fs.removeSync(root); }); - const sorted = (paths: string[]) => [...paths].sort(); - it('returns the matches of relative patterns relative to the current folder', () => { expect(sorted(globPaths(['reports/**/*.xml']))).toEqual([ 'reports/a.xml', @@ -43,12 +45,11 @@ describe('globPaths', () => { ]); }); - it('expands brace lists and ranges', () => { - expect(sorted(globPaths(['reports/{a,b}.xml']))).toEqual([ + it('expands brace lists', () => { + expect(sorted(globPaths(['reports/{a,c}.{xml,json}']))).toEqual([ 'reports/a.xml', - 'reports/b.xml', + 'reports/c.json', ]); - expect(sorted(globPaths(['reports/*.{xml,json}']))).toHaveLength(4); }); it('returns the files of a folder', () => { @@ -61,19 +62,19 @@ describe('globPaths', () => { }); it('returns the matches of absolute patterns as absolute paths', () => { - expect(sorted(globPaths([path.join(cwd, 'dist')]))).toEqual([ - path.join(cwd, 'dist/a.js'), - path.join(cwd, 'dist/sub/b.js'), + expect(sorted(globPaths([posix(cwd, 'dist')]))).toEqual([ + posix(cwd, 'dist/a.js'), + posix(cwd, 'dist/sub/b.js'), ]); - expect(globPaths([path.join(root, 'outside/*.xml')])).toEqual([ - path.join(root, 'outside/r.xml'), + expect(globPaths([posix(root, 'outside/*.xml')])).toEqual([ + posix(root, 'outside/r.xml'), ]); }); it('keeps the form of each pattern when relative and absolute are mixed', () => { expect( - sorted(globPaths(['reports/a.xml', path.join(cwd, 'reports/b.xml')])) - ).toEqual([path.join(cwd, 'reports/b.xml'), 'reports/a.xml']); + sorted(globPaths(['reports/a.xml', posix(cwd, 'reports/b.xml')])) + ).toEqual([posix(cwd, 'reports/b.xml'), 'reports/a.xml']); }); it('leaves out the matches of negated patterns', () => { @@ -81,42 +82,49 @@ describe('globPaths', () => { 'reports/a.xml', 'reports/b.xml', ]); - expect( - sorted( - globPaths([ - path.join(cwd, 'reports/*.xml'), - '!' + path.join(cwd, 'reports/skip.xml'), - ]) - ) - ).toEqual([ - path.join(cwd, 'reports/a.xml'), - path.join(cwd, 'reports/b.xml'), - ]); }); - it('leaves out every file of a negated folder', () => { - expect(globPaths(['reports/**/*.xml', '!reports/nested'])).not.toContain( - 'reports/nested/d.xml' - ); - }); - - // The cases below are how globby 11 behaved - it('keeps the files of a folder whose name matches a negated pattern', () => { - expect(globPaths(['reports/**/*.xml', '!reports/**/n*'])).toContain( - 'reports/nested/d.xml' - ); - }); - - it('applies a negated pattern only to the patterns before it', () => { - expect(globPaths(['!reports/skip.xml', 'reports/*.xml'])).toContain( - 'reports/skip.xml' - ); - }); - - it('leaves nothing out of an absolute pattern with a relative negation', () => { - expect( - globPaths([path.join(cwd, 'reports/*.xml'), '!reports/skip.xml']) - ).toContain(path.join(cwd, 'reports/skip.xml')); + // How globby 11 applied negated patterns. The rows are functions because + // cwd is only set in beforeEach. + it.each([ + [ + 'leaves out every file of a negated folder', + () => ['reports/**/*.xml', '!reports/nested'], + () => 'reports/nested/d.xml', + false, + ], + [ + 'keeps the files of a folder whose name matches a negated pattern', + () => ['reports/**/*.xml', '!reports/**/n*'], + () => 'reports/nested/d.xml', + true, + ], + [ + 'applies a negated pattern only to the patterns before it', + () => ['!reports/skip.xml', 'reports/*.xml'], + () => 'reports/skip.xml', + true, + ], + [ + 'applies an absolute negated pattern to a relative one', + () => ['reports/*.xml', `!${posix(cwd, 'reports/skip.xml')}`], + () => 'reports/skip.xml', + false, + ], + [ + 'applies an absolute negated pattern to an absolute one', + () => [posix(cwd, 'reports/*.xml'), `!${posix(cwd, 'reports/skip.xml')}`], + () => posix(cwd, 'reports/skip.xml'), + false, + ], + [ + 'leaves nothing out of an absolute pattern with a relative negation', + () => [posix(cwd, 'reports/*.xml'), '!reports/skip.xml'], + () => posix(cwd, 'reports/skip.xml'), + true, + ], + ])('%s', (_, patterns, file, kept) => { + expect(globPaths(patterns()).includes(file())).toBe(kept); }); // convert keeps the last report of a spec, so the order decides the results @@ -134,35 +142,6 @@ describe('globPaths', () => { ]); }); - it('sorts one folder name at a time', () => { - for (const file of ['order/a/x.xml', 'order/a-b/x.xml']) { - fs.outputFileSync(path.join(cwd, file), file); - } - - expect(globPaths(['order/**/*.xml'])).toEqual([ - 'order/a/x.xml', - 'order/a-b/x.xml', - ]); - }); - - it('sorts ignoring case on Windows, like its file system', () => { - for (const file of ['order/a.xml', 'order/UPPER.xml', 'order/z.xml']) { - fs.outputFileSync(path.join(cwd, file), file); - } - expect(globPaths(['order/*.xml'])).toEqual([ - 'order/UPPER.xml', - 'order/a.xml', - 'order/z.xml', - ]); - - vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); - expect(globPaths(['order/*.xml'])).toEqual([ - 'order/a.xml', - 'order/UPPER.xml', - 'order/z.xml', - ]); - }); - it('returns nothing for a pattern that matches no file', () => { expect(globPaths(['nope/*.xml'])).toEqual([]); }); @@ -173,10 +152,41 @@ describe('globPaths', () => { expect(globPaths(['reports/a.xml', ''])).toEqual(['reports/a.xml']); }); - // GHSA-vfj7-8cjw-p6xm: braces overflowed the stack on deeply nested groups. + // GHSA-vfj7-8cjw-p6xm: braces overflowed the stack on deeply nested groups it('handles deeply nested braces', () => { const depth = 5000; const pattern = '{'.repeat(depth) + 'a' + '}'.repeat(depth) + '.xml'; expect(globPaths([pattern])).toEqual([]); }); }); + +describe('sortByDepth', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sorts one folder name at a time', () => { + expect(sortByDepth(['a-b/x.xml', 'a/x.xml'])).toEqual([ + 'a/x.xml', + 'a-b/x.xml', + ]); + }); + + it('sorts by character code on macOS and Linux', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + expect(sortByDepth(['z.xml', 'a.xml', 'UPPER.xml'])).toEqual([ + 'UPPER.xml', + 'a.xml', + 'z.xml', + ]); + }); + + it('sorts ignoring case on Windows, like its file system', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + expect(sortByDepth(['z.xml', 'a.xml', 'UPPER.xml'])).toEqual([ + 'a.xml', + 'UPPER.xml', + 'z.xml', + ]); + }); +}); diff --git a/packages/cmd/src/lib/glob.ts b/packages/cmd/src/lib/glob.ts index edcb474e..067814bc 100644 --- a/packages/cmd/src/lib/glob.ts +++ b/packages/cmd/src/lib/glob.ts @@ -4,32 +4,38 @@ import picomatch from 'picomatch'; import { globSync } from 'tinyglobby'; /** - * Returns the files that match the glob patterns, the way globby 11 returned - * them, so that switching from globby changes no command's results: - * - a match of an absolute pattern is an absolute path, and a match of a - * relative pattern is relative to the current folder. tinyglobby alone - * returns every match relative to the current folder, so - * `--input-file /tmp/r.xml` would print as `../../tmp/r.xml`. - * - a `!pattern` leaves out the files whose path matches it, or every file of - * a folder it names, from the patterns before it, and a relative `!pattern` - * leaves nothing out of an absolute pattern's matches. tinyglobby's `ignore` - * skips whole folders whose name matches, so `!reports/a*` would also drop - * `reports/a_old/report.xml`. - * - the matches of each pattern come in the order of the patterns, shallower - * files first. When two reports hold the same spec, `convert` keeps the one - * it reads last, so a different order changes the converted results. + * Returns the files that match the glob patterns as globby 11 did, so that + * replacing it changes no command's results: + * - an absolute pattern returns absolute paths, a relative one paths relative + * to the current folder + * - a `!pattern` leaves out the files it matches, or the files of a folder it + * names, from the patterns before it; a relative one leaves nothing out of + * an absolute pattern + * - files come in pattern order, shallower first. `convert` keeps the last + * report of a spec, so the order decides its results. */ export function globPaths(patterns: string[]): string[] { + const negations = patterns.flatMap((pattern, index) => + pattern.length > 1 && pattern.startsWith('!') + ? [toNegation(pattern.slice(1), index)] + : [] + ); const matches = new Set(); patterns.forEach((pattern, index) => { if (pattern.startsWith('!')) { return; } + // Without the `absolute` option, tinyglobby returns /tmp/r.xml as + // ../../tmp/r.xml const absolute = isAbsolute(pattern); - const isLeftOut = negationMatcher(patterns.slice(index + 1), absolute); + const applying = negations.filter( + (negation) => negation.index > index && (!absolute || negation.absolute) + ); + // Not tinyglobby's `ignore`: it skips whole folders whose name matches, so + // `!reports/a*` would also drop reports/a_old/report.xml const files = globSync(pattern, { absolute }).filter( - (file) => !isLeftOut(file) + (file) => !applying.some((negation) => negation.leavesOut(file)) ); for (const file of sortByDepth(files)) { matches.add(file); @@ -38,27 +44,17 @@ export function globPaths(patterns: string[]): string[] { return [...matches]; } -// Matches the files a pattern found against the `!patterns` after it, instead -// of listing what each `!pattern` matches: `!**/old*` would otherwise walk the -// whole current folder, node_modules included. -function negationMatcher(patterns: string[], absolute: boolean) { - const matchers = patterns - .filter((pattern) => pattern.length > 1 && pattern.startsWith('!')) - .map((pattern) => pattern.slice(1).replace(/\\/g, '/')) - // globby matched a relative !pattern against the absolute paths an - // absolute pattern returns, so it never left anything out of them - .filter((pattern) => !absolute || isAbsolute(pattern)) - .map((pattern) => ({ - absolute: isAbsolute(pattern), - isMatch: picomatch( - isFolder(pattern) ? `${pattern.replace(/\/$/, '')}/**` : pattern - ), - })); - - return (file: string) => - matchers.some(({ absolute, isMatch }) => - isMatch(absolute ? resolve(file).replace(/\\/g, '/') : file) - ); +function toNegation(pattern: string, index: number) { + const absolute = isAbsolute(pattern); + const isMatch = picomatch( + isFolder(pattern) ? `${pattern.replace(/\/$/, '')}/**` : pattern + ); + return { + index, + absolute, + leavesOut: (file: string) => + isMatch(absolute ? resolve(file).replace(/\\/g, '/') : file), + }; } function isFolder(pattern: string) { @@ -76,7 +72,7 @@ function isFolder(pattern: string) { // character code on macOS and Linux, ignoring case on Windows. Comparing whole // paths instead of one folder name at a time would put a-b/x.xml before // a/x.xml. -function sortByDepth(files: string[]) { +export function sortByDepth(files: string[]) { const sortable = files.map((file) => ({ file, segments: (process.platform === 'win32' ? file.toUpperCase() : file).split( diff --git a/packages/cmd/src/services/cache/__tests__/path.spec.ts b/packages/cmd/src/services/cache/__tests__/path.spec.ts index d62fc1ce..2f1e628f 100644 --- a/packages/cmd/src/services/cache/__tests__/path.spec.ts +++ b/packages/cmd/src/services/cache/__tests__/path.spec.ts @@ -6,6 +6,9 @@ import { filterPaths, getLastRunFilePaths, getUploadPaths } from '../path'; vi.mock('../lib', () => ({ warn: vi.fn() })); +// The paths found are joined with / on every platform +const posix = (...parts: string[]) => path.join(...parts).replace(/\\/g, '/'); + describe('cache paths', () => { let root: string; let cwd: string; @@ -41,8 +44,8 @@ describe('cache paths', () => { fs.outputFileSync(path.join(cwd, 'test-results/s1/trace.zip'), 'zip'); expect((await getLastRunFilePaths()).sort()).toEqual([ - path.join(cwd, 'test-results/.last-run.json'), - path.join(cwd, 'test-results/s1/.last-run.json'), + posix(cwd, 'test-results/.last-run.json'), + posix(cwd, 'test-results/s1/.last-run.json'), ]); }); @@ -50,7 +53,7 @@ describe('cache paths', () => { fs.outputFileSync(path.join(root, 'pw-out/.last-run.json'), '{}'); expect(await getLastRunFilePaths(path.join(root, 'pw-out'))).toEqual([ - path.join(root, 'pw-out/.last-run.json'), + posix(root, 'pw-out/.last-run.json'), ]); }); From 7e0be88e9b793fd7a42c76a8e7efe37fbcf6565a Mon Sep 17 00:00:00 2001 From: vCaisim Date: Thu, 22 Oct 2026 15:05:23 +0300 Subject: [PATCH 4/8] fix(cmd): expand numeric brace ranges the way globby did Co-Authored-By: Claude Opus 5.5 --- packages/cmd/src/lib/__tests__/glob.test.ts | 20 ++++++ packages/cmd/src/lib/glob.ts | 69 ++++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/cmd/src/lib/__tests__/glob.test.ts b/packages/cmd/src/lib/__tests__/glob.test.ts index 6b19ab6a..c1513c30 100644 --- a/packages/cmd/src/lib/__tests__/glob.test.ts +++ b/packages/cmd/src/lib/__tests__/glob.test.ts @@ -52,6 +52,26 @@ describe('globPaths', () => { ]); }); + it.each([ + ['reports/{1..10}.xml', ['1', '2', '9', '10']], + ['reports/{2..1}.xml', ['2', '1']], + ['reports/{01..03}.xml', ['01', '03']], + ['reports/n{1..2}/*.xml', ['n1/a', 'n2/a']], + ])('expands the numeric range in %s in its order', (pattern, names) => { + for (const name of ['1', '2', '9', '10', '01', '03', 'n1/a', 'n2/a']) { + fs.outputFileSync(path.join(cwd, `reports/${name}.xml`), name); + } + expect(globPaths([pattern])).toEqual( + names.map((name) => `reports/${name}.xml`) + ); + }); + + it('refuses a numeric range of more than 1000 values, like globby', () => { + expect(() => globPaths(['reports/{1..2000}.xml'])).toThrow( + 'more than 1000 values' + ); + }); + it('returns the files of a folder', () => { expect(sorted(globPaths(['dist']))).toEqual(['dist/a.js', 'dist/sub/b.js']); }); diff --git a/packages/cmd/src/lib/glob.ts b/packages/cmd/src/lib/glob.ts index 067814bc..06a56072 100644 --- a/packages/cmd/src/lib/glob.ts +++ b/packages/cmd/src/lib/glob.ts @@ -14,7 +14,8 @@ import { globSync } from 'tinyglobby'; * - files come in pattern order, shallower first. `convert` keeps the last * report of a spec, so the order decides its results. */ -export function globPaths(patterns: string[]): string[] { +export function globPaths(globs: string[]): string[] { + const patterns = globs.flatMap(expandNumericRanges); const negations = patterns.flatMap((pattern, index) => pattern.length > 1 && pattern.startsWith('!') ? [toNegation(pattern.slice(1), index)] @@ -44,6 +45,72 @@ export function globPaths(patterns: string[]): string[] { return [...matches]; } +const NUMERIC_RANGE = /(? picomatch.scan(p).base)); + if (expanded.length > 1 && !pattern.startsWith('!') && folders.size > 1) { + return expanded; + } + return [ + pattern.replace(new RegExp(NUMERIC_RANGE, 'g'), (_, from, to, step) => { + const values = numericRange(from, to, step); + return values.length === 1 ? values[0] : `{${values.join(',')}}`; + }), + ]; +} + +function writeOutRanges(pattern: string): string[] { + const range = NUMERIC_RANGE.exec(pattern); + if (!range) { + return [pattern]; + } + const [text, from, to, step] = range; + return numericRange(from, to, step).flatMap((value) => + writeOutRanges( + pattern.slice(0, range.index) + + value + + pattern.slice(range.index + text.length) + ) + ); +} + +// Same values as braces: a step of 0 counts as 1, and a bound written with a +// leading zero pads every value to the longest bound +function numericRange(from: string, to: string, step = '1') { + const start = Number(from); + const end = Number(to); + const size = Math.abs(Number(step)) || 1; + if (Math.abs(end - start) / size >= MAX_RANGE_VALUES) { + throw new Error( + `{${from}..${to}} expands to more than ${MAX_RANGE_VALUES} values` + ); + } + const padded = [from, to].some((bound) => /^-?0\d/.test(bound)); + const width = Math.max(from.length, to.length); + const values: string[] = []; + for ( + let value = start; + start <= end ? value <= end : value >= end; + value += start <= end ? size : -size + ) { + const digits = String(Math.abs(value)); + values.push( + (value < 0 ? '-' : '') + + (padded ? digits.padStart(width - (value < 0 ? 1 : 0), '0') : digits) + ); + } + return values; +} + function toNegation(pattern: string, index: number) { const absolute = isAbsolute(pattern); const isMatch = picomatch( From 7480f42199120f05c9a892dc832ccc0b9413c17e Mon Sep 17 00:00:00 2001 From: vCaisim Date: Thu, 22 Oct 2026 15:18:45 +0300 Subject: [PATCH 5/8] refactor(cmd): keep the tinyglobby swap minimal and expand numeric ranges Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 9 - packages/cmd/package.json | 4 +- packages/cmd/src/lib/__tests__/glob.test.ts | 184 +++++------------- packages/cmd/src/lib/glob.ts | 163 +++------------- .../src/services/cache/__tests__/path.spec.ts | 10 +- packages/cmd/src/test-utils/path.ts | 5 + 6 files changed, 89 insertions(+), 286 deletions(-) create mode 100644 packages/cmd/src/test-utils/path.ts diff --git a/package-lock.json b/package-lock.json index 738dc8d1..a6bac6e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5409,13 +5409,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/proxy-from-env": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@types/proxy-from-env/-/proxy-from-env-1.0.4.tgz", @@ -13975,7 +13968,6 @@ "https-proxy-agent": "^7.0.4", "lodash": "^4.18.1", "nanoid": "^3.3.8", - "picomatch": "^4.0.7", "pretty-ms": "^7.0.1", "proxy-from-env": "^1.1.0", "semver": "^7.8.5", @@ -14004,7 +13996,6 @@ "@types/getos": "^3.0.4", "@types/lodash": "^4.17.16", "@types/node": "^25.5.0", - "@types/picomatch": "^4.0.3", "@types/proxy-from-env": "^1.0.4", "@types/semver": "^7.5.8", "@types/tmp": "^0.2.6", diff --git a/packages/cmd/package.json b/packages/cmd/package.json index 6f55610d..0b32e92e 100644 --- a/packages/cmd/package.json +++ b/packages/cmd/package.json @@ -29,7 +29,6 @@ "@jest/reporters": "^30.4.1", "@jest/types": "^30.4.1", "@release-it/conventional-changelog": "^12.0.0", - "release-it": "^21.0.2", "@types/archiver": "^6.0.3", "@types/async-retry": "^1.4.9", "@types/debug": "^4.1.12", @@ -37,7 +36,6 @@ "@types/getos": "^3.0.4", "@types/lodash": "^4.17.16", "@types/node": "^25.5.0", - "@types/picomatch": "^4.0.3", "@types/proxy-from-env": "^1.0.4", "@types/semver": "^7.5.8", "@types/tmp": "^0.2.6", @@ -48,6 +46,7 @@ "jest": "^30.4.2", "jest-cli": "^30.4.2", "jest-config": "^30.4.2", + "release-it": "^21.0.2", "rimraf": "^6.0.1", "tsup": "^8.3.5", "typescript": "^5.9.3", @@ -71,7 +70,6 @@ "https-proxy-agent": "^7.0.4", "lodash": "^4.18.1", "nanoid": "^3.3.8", - "picomatch": "^4.0.7", "pretty-ms": "^7.0.1", "proxy-from-env": "^1.1.0", "semver": "^7.8.5", diff --git a/packages/cmd/src/lib/__tests__/glob.test.ts b/packages/cmd/src/lib/__tests__/glob.test.ts index c1513c30..db414753 100644 --- a/packages/cmd/src/lib/__tests__/glob.test.ts +++ b/packages/cmd/src/lib/__tests__/glob.test.ts @@ -2,11 +2,8 @@ import fs from 'fs-extra'; import os from 'os'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { globPaths, sortByDepth } from '../glob'; - -// Glob patterns use / on every platform, and globPaths returns / paths -const posix = (...parts: string[]) => path.join(...parts).replace(/\\/g, '/'); -const sorted = (paths: string[]) => [...paths].sort(); +import { posixJoin } from '../../test-utils/path'; +import { globPaths } from '../glob'; describe('globPaths', () => { let root: string; @@ -22,6 +19,11 @@ describe('globPaths', () => { 'reports/skip.xml', 'reports/.hidden.xml', 'reports/nested/d.xml', + 'reports/1.xml', + 'reports/2.xml', + 'reports/10.xml', + 'reports/01.xml', + 'reports/03.xml', 'dist/a.js', 'dist/sub/b.js', ]) { @@ -36,44 +38,23 @@ describe('globPaths', () => { fs.removeSync(root); }); - it('returns the matches of relative patterns relative to the current folder', () => { - expect(sorted(globPaths(['reports/**/*.xml']))).toEqual([ + it('returns the matches sorted, relative to the current folder', () => { + expect(globPaths(['reports/**/[a-z].xml'])).toEqual([ 'reports/a.xml', 'reports/b.xml', 'reports/nested/d.xml', - 'reports/skip.xml', ]); }); - it('expands brace lists', () => { - expect(sorted(globPaths(['reports/{a,c}.{xml,json}']))).toEqual([ + it('returns the matches of each pattern in pattern order, once', () => { + expect(globPaths(['reports/b.xml', 'reports/[ab].xml'])).toEqual([ + 'reports/b.xml', 'reports/a.xml', - 'reports/c.json', ]); }); - it.each([ - ['reports/{1..10}.xml', ['1', '2', '9', '10']], - ['reports/{2..1}.xml', ['2', '1']], - ['reports/{01..03}.xml', ['01', '03']], - ['reports/n{1..2}/*.xml', ['n1/a', 'n2/a']], - ])('expands the numeric range in %s in its order', (pattern, names) => { - for (const name of ['1', '2', '9', '10', '01', '03', 'n1/a', 'n2/a']) { - fs.outputFileSync(path.join(cwd, `reports/${name}.xml`), name); - } - expect(globPaths([pattern])).toEqual( - names.map((name) => `reports/${name}.xml`) - ); - }); - - it('refuses a numeric range of more than 1000 values, like globby', () => { - expect(() => globPaths(['reports/{1..2000}.xml'])).toThrow( - 'more than 1000 values' - ); - }); - it('returns the files of a folder', () => { - expect(sorted(globPaths(['dist']))).toEqual(['dist/a.js', 'dist/sub/b.js']); + expect(globPaths(['dist'])).toEqual(['dist/a.js', 'dist/sub/b.js']); }); it('matches a dotfile only by its name', () => { @@ -82,94 +63,56 @@ describe('globPaths', () => { }); it('returns the matches of absolute patterns as absolute paths', () => { - expect(sorted(globPaths([posix(cwd, 'dist')]))).toEqual([ - posix(cwd, 'dist/a.js'), - posix(cwd, 'dist/sub/b.js'), + expect(globPaths([posixJoin(cwd, 'dist')])).toEqual([ + posixJoin(cwd, 'dist/a.js'), + posixJoin(cwd, 'dist/sub/b.js'), ]); - expect(globPaths([posix(root, 'outside/*.xml')])).toEqual([ - posix(root, 'outside/r.xml'), + expect(globPaths([posixJoin(root, 'outside/*.xml')])).toEqual([ + posixJoin(root, 'outside/r.xml'), ]); }); - it('keeps the form of each pattern when relative and absolute are mixed', () => { - expect( - sorted(globPaths(['reports/a.xml', posix(cwd, 'reports/b.xml')])) - ).toEqual([posix(cwd, 'reports/b.xml'), 'reports/a.xml']); - }); - - it('leaves out the matches of negated patterns', () => { - expect(sorted(globPaths(['reports/*.xml', '!reports/skip.xml']))).toEqual([ + it('expands brace lists', () => { + expect(globPaths(['reports/{a,c}.{xml,json}'])).toEqual([ 'reports/a.xml', - 'reports/b.xml', + 'reports/c.json', ]); }); - // How globby 11 applied negated patterns. The rows are functions because - // cwd is only set in beforeEach. it.each([ - [ - 'leaves out every file of a negated folder', - () => ['reports/**/*.xml', '!reports/nested'], - () => 'reports/nested/d.xml', - false, - ], - [ - 'keeps the files of a folder whose name matches a negated pattern', - () => ['reports/**/*.xml', '!reports/**/n*'], - () => 'reports/nested/d.xml', - true, - ], - [ - 'applies a negated pattern only to the patterns before it', - () => ['!reports/skip.xml', 'reports/*.xml'], - () => 'reports/skip.xml', - true, - ], - [ - 'applies an absolute negated pattern to a relative one', - () => ['reports/*.xml', `!${posix(cwd, 'reports/skip.xml')}`], - () => 'reports/skip.xml', - false, - ], - [ - 'applies an absolute negated pattern to an absolute one', - () => [posix(cwd, 'reports/*.xml'), `!${posix(cwd, 'reports/skip.xml')}`], - () => posix(cwd, 'reports/skip.xml'), - false, - ], - [ - 'leaves nothing out of an absolute pattern with a relative negation', - () => [posix(cwd, 'reports/*.xml'), '!reports/skip.xml'], - () => posix(cwd, 'reports/skip.xml'), - true, - ], - ])('%s', (_, patterns, file, kept) => { - expect(globPaths(patterns()).includes(file())).toBe(kept); + ['reports/{1..10}.xml', ['1', '10', '2']], + ['reports/{2..1}.xml', ['1', '2']], + ['reports/{01..03}.xml', ['01', '03']], + ['reports/{1..10..9}.xml', ['1', '10']], + ])('expands the numeric range in %s', (pattern, names) => { + expect(globPaths([pattern])).toEqual( + names.map((name) => `reports/${name}.xml`) + ); }); - // convert keeps the last report of a spec, so the order decides the results - it('returns the matches in pattern order, shallower files first', () => { - expect(globPaths(['reports/**/*.xml'])).toEqual([ - 'reports/a.xml', - 'reports/b.xml', - 'reports/skip.xml', - 'reports/nested/d.xml', - ]); - expect(globPaths(['reports/skip.xml', 'reports/*.xml'])).toEqual([ - 'reports/skip.xml', - 'reports/a.xml', - 'reports/b.xml', - ]); + it('refuses a numeric range of more than 1000 values, like globby', () => { + expect(() => globPaths(['reports/{1..2000}.xml'])).toThrow( + 'more than 1000 values' + ); }); - it('returns nothing for a pattern that matches no file', () => { - expect(globPaths(['nope/*.xml'])).toEqual([]); + it('leaves out the matches of a negated pattern from every pattern', () => { + expect( + globPaths(['reports/[as]*.xml', '!reports/skip.xml', 'reports/s*.xml']) + ).toEqual(['reports/a.xml']); }); - // globby 11 matched every file in the current folder for an empty pattern, - // so a trailing comma in --input-file made convert read all of them - it('matches nothing for an empty pattern', () => { - expect(globPaths(['reports/a.xml', ''])).toEqual(['reports/a.xml']); + it('leaves out the files of a negated folder', () => { + expect(globPaths(['reports/**/*.xml', '!reports/nested'])).not.toContain( + 'reports/nested/d.xml' + ); + }); + + // An empty pattern is what a trailing comma in --input-file leaves + it('matches nothing for an empty pattern or one with no match', () => { + expect(globPaths(['reports/a.xml', '', 'nope/*.xml'])).toEqual([ + 'reports/a.xml', + ]); }); // GHSA-vfj7-8cjw-p6xm: braces overflowed the stack on deeply nested groups @@ -179,34 +122,3 @@ describe('globPaths', () => { expect(globPaths([pattern])).toEqual([]); }); }); - -describe('sortByDepth', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('sorts one folder name at a time', () => { - expect(sortByDepth(['a-b/x.xml', 'a/x.xml'])).toEqual([ - 'a/x.xml', - 'a-b/x.xml', - ]); - }); - - it('sorts by character code on macOS and Linux', () => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); - expect(sortByDepth(['z.xml', 'a.xml', 'UPPER.xml'])).toEqual([ - 'UPPER.xml', - 'a.xml', - 'z.xml', - ]); - }); - - it('sorts ignoring case on Windows, like its file system', () => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); - expect(sortByDepth(['z.xml', 'a.xml', 'UPPER.xml'])).toEqual([ - 'a.xml', - 'UPPER.xml', - 'z.xml', - ]); - }); -}); diff --git a/packages/cmd/src/lib/glob.ts b/packages/cmd/src/lib/glob.ts index 06a56072..9b4c3147 100644 --- a/packages/cmd/src/lib/glob.ts +++ b/packages/cmd/src/lib/glob.ts @@ -1,86 +1,36 @@ -import fs from 'fs'; -import { isAbsolute, resolve } from 'path'; -import picomatch from 'picomatch'; +import { isAbsolute } from 'path'; import { globSync } from 'tinyglobby'; /** - * Returns the files that match the glob patterns as globby 11 did, so that - * replacing it changes no command's results: - * - an absolute pattern returns absolute paths, a relative one paths relative - * to the current folder - * - a `!pattern` leaves out the files it matches, or the files of a folder it - * names, from the patterns before it; a relative one leaves nothing out of - * an absolute pattern - * - files come in pattern order, shallower first. `convert` keeps the last - * report of a spec, so the order decides its results. + * Returns the files that match the glob patterns, pattern by pattern and + * sorted within each, without duplicates. A `!pattern` leaves its matches out + * of every pattern. */ export function globPaths(globs: string[]): string[] { - const patterns = globs.flatMap(expandNumericRanges); - const negations = patterns.flatMap((pattern, index) => - pattern.length > 1 && pattern.startsWith('!') - ? [toNegation(pattern.slice(1), index)] - : [] - ); - const matches = new Set(); - - patterns.forEach((pattern, index) => { - if (pattern.startsWith('!')) { - return; - } - // Without the `absolute` option, tinyglobby returns /tmp/r.xml as - // ../../tmp/r.xml - const absolute = isAbsolute(pattern); - const applying = negations.filter( - (negation) => negation.index > index && (!absolute || negation.absolute) - ); - // Not tinyglobby's `ignore`: it skips whole folders whose name matches, so - // `!reports/a*` would also drop reports/a_old/report.xml - const files = globSync(pattern, { absolute }).filter( - (file) => !applying.some((negation) => negation.leavesOut(file)) + const patterns = globs.map(expandNumericRanges); + const ignore = patterns + .filter((pattern) => pattern.startsWith('!')) + .map((pattern) => pattern.slice(1)); + const files = patterns + .filter((pattern) => !pattern.startsWith('!')) + .flatMap((pattern) => + // Without `absolute`, tinyglobby returns /tmp/r.xml as ../../tmp/r.xml + globSync(pattern, { absolute: isAbsolute(pattern), ignore }).sort() ); - for (const file of sortByDepth(files)) { - matches.add(file); - } - }); - return [...matches]; + return [...new Set(files)]; } -const NUMERIC_RANGE = /(? picomatch.scan(p).base)); - if (expanded.length > 1 && !pattern.startsWith('!') && folders.size > 1) { - return expanded; - } - return [ - pattern.replace(new RegExp(NUMERIC_RANGE, 'g'), (_, from, to, step) => { - const values = numericRange(from, to, step); - return values.length === 1 ? values[0] : `{${values.join(',')}}`; - }), - ]; -} - -function writeOutRanges(pattern: string): string[] { - const range = NUMERIC_RANGE.exec(pattern); - if (!range) { - return [pattern]; - } - const [text, from, to, step] = range; - return numericRange(from, to, step).flatMap((value) => - writeOutRanges( - pattern.slice(0, range.index) + - value + - pattern.slice(range.index + text.length) - ) - ); +// tinyglobby reads {1..10} as the character class [1-10], which matches only +// 0 and 1, and matches nothing for {01..03} +function expandNumericRanges(pattern: string) { + return pattern.replace(NUMERIC_RANGE, (_, from, to, step) => { + const values = numericRange(from, to, step); + return values.length === 1 ? values[0] : `{${values.join(',')}}`; + }); } // Same values as braces: a step of 0 counts as 1, and a bound written with a @@ -89,69 +39,18 @@ function numericRange(from: string, to: string, step = '1') { const start = Number(from); const end = Number(to); const size = Math.abs(Number(step)) || 1; - if (Math.abs(end - start) / size >= MAX_RANGE_VALUES) { + const count = Math.floor(Math.abs(end - start) / size) + 1; + if (count > MAX_RANGE_VALUES) { throw new Error( `{${from}..${to}} expands to more than ${MAX_RANGE_VALUES} values` ); } + const direction = start <= end ? 1 : -1; const padded = [from, to].some((bound) => /^-?0\d/.test(bound)); - const width = Math.max(from.length, to.length); - const values: string[] = []; - for ( - let value = start; - start <= end ? value <= end : value >= end; - value += start <= end ? size : -size - ) { - const digits = String(Math.abs(value)); - values.push( - (value < 0 ? '-' : '') + - (padded ? digits.padStart(width - (value < 0 ? 1 : 0), '0') : digits) - ); - } - return values; -} - -function toNegation(pattern: string, index: number) { - const absolute = isAbsolute(pattern); - const isMatch = picomatch( - isFolder(pattern) ? `${pattern.replace(/\/$/, '')}/**` : pattern - ); - return { - index, - absolute, - leavesOut: (file: string) => - isMatch(absolute ? resolve(file).replace(/\\/g, '/') : file), - }; -} - -function isFolder(pattern: string) { - if (picomatch.scan(pattern).isGlob) { - return false; - } - try { - return fs.statSync(resolve(pattern)).isDirectory(); - } catch { - return false; - } -} - -// globby listed each folder in the order the file system returns it: by -// character code on macOS and Linux, ignoring case on Windows. Comparing whole -// paths instead of one folder name at a time would put a-b/x.xml before -// a/x.xml. -export function sortByDepth(files: string[]) { - const sortable = files.map((file) => ({ - file, - segments: (process.platform === 'win32' ? file.toUpperCase() : file).split( - '/' - ), - })); - sortable.sort(({ segments: sa }, { segments: sb }) => { - if (sa.length !== sb.length) { - return sa.length - sb.length; - } - const i = sa.findIndex((segment, index) => segment !== sb[index]); - return i === -1 ? 0 : sa[i] < sb[i] ? -1 : 1; + const width = padded ? Math.max(from.length, to.length) : 0; + return Array.from({ length: count }, (_, i) => { + const value = start + i * direction * size; + const sign = value < 0 ? '-' : ''; + return sign + String(Math.abs(value)).padStart(width - sign.length, '0'); }); - return sortable.map(({ file }) => file); } diff --git a/packages/cmd/src/services/cache/__tests__/path.spec.ts b/packages/cmd/src/services/cache/__tests__/path.spec.ts index 2f1e628f..fa625d0e 100644 --- a/packages/cmd/src/services/cache/__tests__/path.spec.ts +++ b/packages/cmd/src/services/cache/__tests__/path.spec.ts @@ -2,13 +2,11 @@ import fs from 'fs-extra'; import os from 'os'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { posixJoin } from '../../../test-utils/path'; import { filterPaths, getLastRunFilePaths, getUploadPaths } from '../path'; vi.mock('../lib', () => ({ warn: vi.fn() })); -// The paths found are joined with / on every platform -const posix = (...parts: string[]) => path.join(...parts).replace(/\\/g, '/'); - describe('cache paths', () => { let root: string; let cwd: string; @@ -44,8 +42,8 @@ describe('cache paths', () => { fs.outputFileSync(path.join(cwd, 'test-results/s1/trace.zip'), 'zip'); expect((await getLastRunFilePaths()).sort()).toEqual([ - posix(cwd, 'test-results/.last-run.json'), - posix(cwd, 'test-results/s1/.last-run.json'), + posixJoin(cwd, 'test-results/.last-run.json'), + posixJoin(cwd, 'test-results/s1/.last-run.json'), ]); }); @@ -53,7 +51,7 @@ describe('cache paths', () => { fs.outputFileSync(path.join(root, 'pw-out/.last-run.json'), '{}'); expect(await getLastRunFilePaths(path.join(root, 'pw-out'))).toEqual([ - posix(root, 'pw-out/.last-run.json'), + posixJoin(root, 'pw-out/.last-run.json'), ]); }); diff --git a/packages/cmd/src/test-utils/path.ts b/packages/cmd/src/test-utils/path.ts new file mode 100644 index 00000000..73c66fe7 --- /dev/null +++ b/packages/cmd/src/test-utils/path.ts @@ -0,0 +1,5 @@ +import path from 'path'; + +/** Joins with / on every platform, the way glob patterns and matches are written. */ +export const posixJoin = (...parts: string[]) => + path.join(...parts).replace(/\\/g, '/'); From 420bae7f76ccf19f65a6ad4d47fa6ad6d9fb105e Mon Sep 17 00:00:00 2001 From: vCaisim Date: Thu, 22 Oct 2026 15:26:17 +0300 Subject: [PATCH 6/8] chore(cmd): keep release-it in its place in package.json Co-Authored-By: Claude Opus 5.5 --- packages/cmd/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cmd/package.json b/packages/cmd/package.json index 0b32e92e..68758b31 100644 --- a/packages/cmd/package.json +++ b/packages/cmd/package.json @@ -29,6 +29,7 @@ "@jest/reporters": "^30.4.1", "@jest/types": "^30.4.1", "@release-it/conventional-changelog": "^12.0.0", + "release-it": "^21.0.2", "@types/archiver": "^6.0.3", "@types/async-retry": "^1.4.9", "@types/debug": "^4.1.12", @@ -46,7 +47,6 @@ "jest": "^30.4.2", "jest-cli": "^30.4.2", "jest-config": "^30.4.2", - "release-it": "^21.0.2", "rimraf": "^6.0.1", "tsup": "^8.3.5", "typescript": "^5.9.3", From 0c894aef3eefc890bbb2c6227dd3d83773d8ba55 Mon Sep 17 00:00:00 2001 From: vCaisim Date: Thu, 22 Oct 2026 15:30:13 +0300 Subject: [PATCH 7/8] test(cmd): cover escaped, nested and negated ranges and negations on absolute patterns Co-Authored-By: Claude Opus 5.5 --- packages/cmd/src/lib/__tests__/glob.test.ts | 38 +++++++++++++++++-- packages/cmd/src/lib/glob.ts | 6 ++- .../src/services/cache/__tests__/path.spec.ts | 2 +- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/cmd/src/lib/__tests__/glob.test.ts b/packages/cmd/src/lib/__tests__/glob.test.ts index db414753..44422b9d 100644 --- a/packages/cmd/src/lib/__tests__/glob.test.ts +++ b/packages/cmd/src/lib/__tests__/glob.test.ts @@ -84,24 +84,54 @@ describe('globPaths', () => { ['reports/{2..1}.xml', ['1', '2']], ['reports/{01..03}.xml', ['01', '03']], ['reports/{1..10..9}.xml', ['1', '10']], + ['reports/{a,{1..2}}.xml', ['1', '2', 'a']], ])('expands the numeric range in %s', (pattern, names) => { expect(globPaths([pattern])).toEqual( names.map((name) => `reports/${name}.xml`) ); }); - it('refuses a numeric range of more than 1000 values, like globby', () => { - expect(() => globPaths(['reports/{1..2000}.xml'])).toThrow( - 'more than 1000 values' - ); + it('leaves an escaped range as written', () => { + fs.outputFileSync(path.join(cwd, 'reports/{1..2}.xml'), 'literal'); + expect(globPaths(['reports/\\{1..2\\}.xml'])).toEqual([ + 'reports/{1..2}.xml', + ]); + }); + + it('expands a numeric range in a negated pattern', () => { + expect(globPaths(['reports/*.xml', '!reports/{1..10}.xml'])).toEqual([ + 'reports/01.xml', + 'reports/03.xml', + 'reports/a.xml', + 'reports/b.xml', + 'reports/skip.xml', + ]); }); + it.each(['{1..2000}', '{2000..1}', '{1..4000..2}'])( + 'refuses the numeric range %s, which has more than 1000 values', + (range) => { + expect(() => globPaths([`reports/${range}.xml`])).toThrow( + `${range} expands to more than 1000 values` + ); + } + ); + it('leaves out the matches of a negated pattern from every pattern', () => { expect( globPaths(['reports/[as]*.xml', '!reports/skip.xml', 'reports/s*.xml']) ).toEqual(['reports/a.xml']); }); + it.each([ + ['relative', () => '!reports/skip.xml'], + ['absolute', () => `!${posixJoin(cwd, 'reports/skip.xml')}`], + ])('applies a %s negated pattern to an absolute one', (_, negation) => { + expect( + globPaths([posixJoin(cwd, 'reports/[as]*.xml'), negation()]) + ).toEqual([posixJoin(cwd, 'reports/a.xml')]); + }); + it('leaves out the files of a negated folder', () => { expect(globPaths(['reports/**/*.xml', '!reports/nested'])).not.toContain( 'reports/nested/d.xml' diff --git a/packages/cmd/src/lib/glob.ts b/packages/cmd/src/lib/glob.ts index 9b4c3147..b3c37eea 100644 --- a/packages/cmd/src/lib/glob.ts +++ b/packages/cmd/src/lib/glob.ts @@ -21,7 +21,8 @@ export function globPaths(globs: string[]): string[] { } const NUMERIC_RANGE = /(? MAX_RANGE_VALUES) { + const range = [from, to, ...(step === '1' ? [] : [step])].join('..'); throw new Error( - `{${from}..${to}} expands to more than ${MAX_RANGE_VALUES} values` + `{${range}} expands to more than ${MAX_RANGE_VALUES} values` ); } const direction = start <= end ? 1 : -1; diff --git a/packages/cmd/src/services/cache/__tests__/path.spec.ts b/packages/cmd/src/services/cache/__tests__/path.spec.ts index fa625d0e..d06bdaaf 100644 --- a/packages/cmd/src/services/cache/__tests__/path.spec.ts +++ b/packages/cmd/src/services/cache/__tests__/path.spec.ts @@ -33,7 +33,7 @@ describe('cache paths', () => { 'dist/../../outside.txt', ]); - expect(paths).toEqual([path.join(cwd, 'dist/a.js')]); + expect(paths).toEqual([posixJoin(cwd, 'dist/a.js')]); }); it('finds every .last-run.json under the Playwright output folder', async () => { From 6c1b01518a5ea907f8bd861bf701fc26d2d4db23 Mon Sep 17 00:00:00 2001 From: vCaisim Date: Thu, 22 Oct 2026 15:34:23 +0300 Subject: [PATCH 8/8] test(cmd): cover negative, combined and 1000-value ranges Co-Authored-By: Claude Opus 5.5 --- packages/cmd/src/lib/__tests__/glob.test.ts | 26 +++++++++++++++++++++ packages/cmd/src/lib/glob.ts | 8 +++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/cmd/src/lib/__tests__/glob.test.ts b/packages/cmd/src/lib/__tests__/glob.test.ts index 44422b9d..0c31c4c9 100644 --- a/packages/cmd/src/lib/__tests__/glob.test.ts +++ b/packages/cmd/src/lib/__tests__/glob.test.ts @@ -1,10 +1,18 @@ import fs from 'fs-extra'; import os from 'os'; import path from 'path'; +import { globSync } from 'tinyglobby'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { posixJoin } from '../../test-utils/path'; import { globPaths } from '../glob'; +// Wrapped so one test can return files out of order: macOS lists folders +// sorted, so only a fixed tinyglobby result shows that globPaths sorts +vi.mock('tinyglobby', async (importActual) => { + const actual = await importActual(); + return { ...actual, globSync: vi.fn(actual.globSync) }; +}); + describe('globPaths', () => { let root: string; let cwd: string; @@ -38,6 +46,14 @@ describe('globPaths', () => { fs.removeSync(root); }); + it('sorts the matches of a pattern', () => { + vi.mocked(globSync).mockReturnValueOnce(['reports/b.xml', 'reports/a.xml']); + expect(globPaths(['reports/*.xml'])).toEqual([ + 'reports/a.xml', + 'reports/b.xml', + ]); + }); + it('returns the matches sorted, relative to the current folder', () => { expect(globPaths(['reports/**/[a-z].xml'])).toEqual([ 'reports/a.xml', @@ -85,12 +101,22 @@ describe('globPaths', () => { ['reports/{01..03}.xml', ['01', '03']], ['reports/{1..10..9}.xml', ['1', '10']], ['reports/{a,{1..2}}.xml', ['1', '2', 'a']], + ['reports/{0..1}{0..3}.xml', ['01', '03', '10']], + ['reports/{1..1000}.xml', ['1', '10', '2']], ])('expands the numeric range in %s', (pattern, names) => { expect(globPaths([pattern])).toEqual( names.map((name) => `reports/${name}.xml`) ); }); + it('expands a numeric range with negative values', () => { + fs.outputFileSync(path.join(cwd, 'reports/-1.xml'), '-1'); + expect(globPaths(['reports/{-1..1}.xml'])).toEqual([ + 'reports/-1.xml', + 'reports/1.xml', + ]); + }); + it('leaves an escaped range as written', () => { fs.outputFileSync(path.join(cwd, 'reports/{1..2}.xml'), 'literal'); expect(globPaths(['reports/\\{1..2\\}.xml'])).toEqual([ diff --git a/packages/cmd/src/lib/glob.ts b/packages/cmd/src/lib/glob.ts index b3c37eea..d07d6100 100644 --- a/packages/cmd/src/lib/glob.ts +++ b/packages/cmd/src/lib/glob.ts @@ -3,8 +3,8 @@ import { globSync } from 'tinyglobby'; /** * Returns the files that match the glob patterns, pattern by pattern and - * sorted within each, without duplicates. A `!pattern` leaves its matches out - * of every pattern. + * sorted within each, without repeating a path. A `!pattern` leaves its + * matches out of every pattern. */ export function globPaths(globs: string[]): string[] { const patterns = globs.map(expandNumericRanges); @@ -21,8 +21,8 @@ export function globPaths(globs: string[]): string[] { } const NUMERIC_RANGE = /(?