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..0c31c4c9 --- /dev/null +++ b/packages/cmd/src/lib/__tests__/glob.test.ts @@ -0,0 +1,180 @@ +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; + + 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', + 'reports/1.xml', + 'reports/2.xml', + 'reports/10.xml', + 'reports/01.xml', + 'reports/03.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); + }); + + 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', + 'reports/b.xml', + 'reports/nested/d.xml', + ]); + }); + + 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', + ]); + }); + + it('returns the files of a folder', () => { + expect(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(globPaths([posixJoin(cwd, 'dist')])).toEqual([ + posixJoin(cwd, 'dist/a.js'), + posixJoin(cwd, 'dist/sub/b.js'), + ]); + expect(globPaths([posixJoin(root, 'outside/*.xml')])).toEqual([ + posixJoin(root, 'outside/r.xml'), + ]); + }); + + it('expands brace lists', () => { + expect(globPaths(['reports/{a,c}.{xml,json}'])).toEqual([ + 'reports/a.xml', + 'reports/c.json', + ]); + }); + + it.each([ + ['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']], + ['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([ + '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' + ); + }); + + // 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 + 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..d07d6100 --- /dev/null +++ b/packages/cmd/src/lib/glob.ts @@ -0,0 +1,58 @@ +import { isAbsolute } from 'path'; +import { globSync } from 'tinyglobby'; + +/** + * Returns the files that match the glob patterns, pattern by pattern and + * 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); + 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() + ); + return [...new Set(files)]; +} + +const NUMERIC_RANGE = /(? { + 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 +// 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; + const count = Math.floor(Math.abs(end - start) / size) + 1; + if (count > MAX_RANGE_VALUES) { + const range = [from, to, ...(step === '1' ? [] : [step])].join('..'); + throw new Error( + `{${range}} 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 = 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'); + }); +} diff --git a/packages/cmd/src/services/cache/__tests__/path.spec.ts b/packages/cmd/src/services/cache/__tests__/path.spec.ts index ced50f09..d06bdaaf 100644 --- a/packages/cmd/src/services/cache/__tests__/path.spec.ts +++ b/packages/cmd/src/services/cache/__tests__/path.spec.ts @@ -2,7 +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 { filterPaths, getUploadPaths } from '../path'; +import { posixJoin } from '../../../test-utils/path'; +import { filterPaths, getLastRunFilePaths, getUploadPaths } from '../path'; vi.mock('../lib', () => ({ warn: vi.fn() })); @@ -32,7 +33,26 @@ 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 () => { + 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([ + posixJoin(cwd, 'test-results/.last-run.json'), + posixJoin(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([ + posixJoin(root, 'pw-out/.last-run.json'), + ]); }); it.each(['../outside.txt', '/etc/hosts', 'dist/../../outside.txt', '..'])( 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. */ 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, '/');