diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..40fb0d4 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +enable-global-virtual-store=true diff --git a/e2e/__snapshots__/index.test.ts.snap b/e2e/__snapshots__/index.test.ts.snap index c3eac50..443fcd8 100644 --- a/e2e/__snapshots__/index.test.ts.snap +++ b/e2e/__snapshots__/index.test.ts.snap @@ -2,6 +2,6 @@ exports[`should serialize file content 2`] = ` "\\"use strict\\"; -const a = \\"/node_modules//@rslib/core/dist/index.js\\"; +const a = \\"/@rslib/core/dist/index.js\\"; " `; diff --git a/e2e/features.test.ts b/e2e/features.test.ts index 6900bd2..6b1ff1f 100644 --- a/e2e/features.test.ts +++ b/e2e/features.test.ts @@ -1,7 +1,7 @@ import path from 'node:path'; +import { createSnapshotSerializer } from 'path-serializer'; import { expect } from 'vitest'; import { test } from 'vitest'; -import { createSnapshotSerializer } from 'path-serializer'; expect.addSnapshotSerializer( createSnapshotSerializer({ @@ -20,7 +20,7 @@ const a = "${require.resolve('@rslib/core')}"; expect(fileContent).toMatchInlineSnapshot(` "use strict"; - const a = "/node_modules//@rslib/core/dist/index.js"; + const a = "/@rslib/core/dist/index.js"; `); }); @@ -34,7 +34,7 @@ function _class_private_method_get(receiver, privateSet, fn) { `; expect(fileContent).toMatchInlineSnapshot(` - ;// CONCATENATED MODULE: ../../../../node_modules//@swc/helpers/esm/_class_private_method_get.js + ;// CONCATENATED MODULE: /@swc/helpers/esm/_class_private_method_get.js function _class_private_method_get(receiver, privateSet, fn) { if (!privateSet.has(receiver)) throw new TypeError("attempted to get private field on non-instance"); diff --git a/e2e/index.test.ts b/e2e/index.test.ts index 69324f1..b453206 100644 --- a/e2e/index.test.ts +++ b/e2e/index.test.ts @@ -1,7 +1,7 @@ import { createRequire } from 'node:module'; import path from 'node:path'; -import { expect, test } from 'vitest'; import { createSnapshotSerializer } from 'path-serializer'; +import { expect, test } from 'vitest'; const require = createRequire(import.meta.url); @@ -23,7 +23,7 @@ test('should serialize and ', () => { test('should serialize ', () => { expect(require.resolve('@rslib/core')).toMatchInlineSnapshot( - `"/node_modules//@rslib/core/dist/index.js"`, + `"/@rslib/core/dist/index.js"`, ); }); @@ -38,7 +38,7 @@ test('should serialize Object', () => { const obj = new MyObj(require.resolve('@rslib/core')); expect(obj).toMatchInlineSnapshot(` MyObj { - "attr": "/node_modules//@rslib/core/dist/index.js", + "attr": "/@rslib/core/dist/index.js", } `); }); @@ -50,7 +50,7 @@ const a = "${require.resolve('@rslib/core')}"; expect(fileContent).toMatchInlineSnapshot(` "\\"use strict\\"; - const a = \\"/node_modules//@rslib/core/dist/index.js\\"; + const a = \\"/@rslib/core/dist/index.js\\"; " `); diff --git a/e2e/pnpmGlobalVirtualStore.test.ts b/e2e/pnpmGlobalVirtualStore.test.ts new file mode 100644 index 0000000..8fe8947 --- /dev/null +++ b/e2e/pnpmGlobalVirtualStore.test.ts @@ -0,0 +1,49 @@ +import { createSnapshotSerializer } from 'path-serializer'; +import { expect, test } from 'vitest'; + +expect.addSnapshotSerializer( + createSnapshotSerializer({ + root: '/Users/user/project', + }), +); + +test('should serialize pnpm global virtual store path (posix)', () => { + const filePath = + '/Users/user/project/node_modules/../../../../../../Library/pnpm/store/v10/links/react/19.2.4/5c6e83be0e5f1f15e83462f0b7655d9d9338d33338bad7cc29bc12f2daa11aa3/node_modules/react/index.js'; + + expect(filePath).toMatchInlineSnapshot(`"/react/index.js"`); +}); + +test('should serialize pnpm global virtual store path with cjs (posix)', () => { + const filePath = + '/Users/user/project/node_modules/../../../../../../Library/pnpm/store/v10/links/react/19.2.4/5c6e83be0e5f1f15e83462f0b7655d9d9338d33338bad7cc29bc12f2daa11aa3/node_modules/react/cjs/react.production.js'; + + expect(filePath).toMatchInlineSnapshot( + `"/react/cjs/react.production.js"`, + ); +}); + +test('should serialize pnpm global virtual store path with scoped package', () => { + const filePath = + '/Users/user/project/node_modules/../../../../../../Library/pnpm/store/v10/links/@babel/core/7.25.0/abc123def456/node_modules/@babel/core/lib/index.js'; + + expect(filePath).toMatchInlineSnapshot( + `"/@babel/core/lib/index.js"`, + ); +}); + +test('should serialize pnpm global virtual store path (win32)', () => { + const serializer = createSnapshotSerializer({ + root: 'D:\\user\\project', + features: { + transformWin32Path: true, + }, + }); + + const filePath = + 'D:\\user\\project\\node_modules\\..\\..\\..\\..\\..\\..\\Library\\pnpm\\store\\v10\\links\\react\\19.2.4\\5c6e83be0e5f1f15e83462f0b7655d9d9338d33338bad7cc29bc12f2daa11aa3\\node_modules\\react\\index.js'; + + expect(serializer.serialize(filePath)).toMatchInlineSnapshot( + `"\\"/react/index.js\\""`, + ); +}); diff --git a/e2e/pnpmInnerWin32.test.ts b/e2e/pnpmInnerWin32.test.ts index bb8c83a..855d106 100644 --- a/e2e/pnpmInnerWin32.test.ts +++ b/e2e/pnpmInnerWin32.test.ts @@ -1,5 +1,5 @@ -import { expect, test } from 'vitest'; import { createSnapshotSerializer } from 'path-serializer'; +import { expect, test } from 'vitest'; expect.addSnapshotSerializer( createSnapshotSerializer({ @@ -21,7 +21,7 @@ test('should serialize in win32', () => { expect(fileContent).toMatchInlineSnapshot(` { - loader: /node_modules//css-loader/utils.ts, + loader: /css-loader/utils.ts, } `); }); @@ -34,7 +34,7 @@ test('should serialize with webpack path', () => { expect(fileContent).toMatchInlineSnapshot(` { - moduleIdentifier: /node_modules//css-loader/dist/cjs.js!/tests/fixtures/css/style.css, + moduleIdentifier: /css-loader/dist/cjs.js!/tests/fixtures/css/style.css, } `); }); diff --git a/package.json b/package.json index 2eeb424..c743186 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "fs-extra": "11.2.0", "semver": "7.6.3" }, - "packageManager": "pnpm@9.14.4", + "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" diff --git a/src/createSnapshotSerializer.ts b/src/createSnapshotSerializer.ts index 4ae4bdc..ff74c3f 100644 --- a/src/createSnapshotSerializer.ts +++ b/src/createSnapshotSerializer.ts @@ -1,15 +1,12 @@ import { pathToFileURL } from 'node:url'; import { applyMatcherReplacement } from './applyMatcherReplacement'; -import { - createHomeDirMatchers, - createPnpmInnerMatchers, - createTmpDirMatchers, -} from './matchers'; +import { createHomeDirMatchers, createTmpDirMatchers } from './matchers'; import { normalizeCLR, normalizeCodeToPosix, normalizePathToPosix, } from './normalize'; +import { replacePnpmInnerPath } from './pnpm'; import type { PathMatcher, SnapshotSerializerOptions } from './types'; export interface SnapshotSerializer { @@ -54,9 +51,6 @@ export function createSnapshotSerializer( if (replaceRoot && root) { pathMatchers.push({ mark: 'root', match: root }); } - if (replacePnpmInner) { - pathMatchers.push(...createPnpmInnerMatchers()); - } if (replaceTmpDir) { pathMatchers.push(...createTmpDirMatchers()); } @@ -106,6 +100,10 @@ export function createSnapshotSerializer( replaced = applyMatcherReplacement(pathMatchers, replaced); + if (replacePnpmInner) { + replaced = replacePnpmInnerPath(replaced); + } + if (transformCLR) { replaced = normalizeCLR(replaced); } diff --git a/src/matchers.ts b/src/matchers.ts index 0ba3702..38022d6 100644 --- a/src/matchers.ts +++ b/src/matchers.ts @@ -18,18 +18,3 @@ export const createHomeDirMatchers = (): PathMatcher[] => { homedir && ret.push({ match: homedir, mark: 'home' }); return ret; }; - -export const createPnpmInnerMatchers = (): PathMatcher[] => { - return [ - // posix - { - match: /(?<=\/)(\.pnpm\/.+?\/node_modules)(?=\/)/g, - mark: 'pnpmInner', - }, - // win32 - { - match: /(?<=\\)(\.pnpm\\.+?\\node_modules)(?=\\)/g, - mark: 'pnpmInner', - }, - ]; -}; diff --git a/src/pnpm/index.ts b/src/pnpm/index.ts new file mode 100644 index 0000000..dfc5fbe --- /dev/null +++ b/src/pnpm/index.ts @@ -0,0 +1 @@ +export { replacePnpmInnerPath } from './replacePnpmInnerPath'; diff --git a/src/pnpm/replacePnpmInnerPath.ts b/src/pnpm/replacePnpmInnerPath.ts new file mode 100644 index 0000000..11aa667 --- /dev/null +++ b/src/pnpm/replacePnpmInnerPath.ts @@ -0,0 +1,38 @@ +const PNPM_INNER_TOKEN = ''; +const PNPM_INNER_DELIMITERS = /[\s!"']/; + +// local virtual store: .pnpm/@/node_modules +const LOCAL_PNPM_POSIX = /(?<=\/)(\.pnpm\/.+?\/node_modules)(?=\/)/g; +const LOCAL_PNPM_WIN32 = /(?<=\\)(\.pnpm\\.+?\\node_modules)(?=\\)/g; +// global virtual store: pnpm/store//links////node_modules +const GLOBAL_PNPM_POSIX = + /(?<=\/)pnpm\/store\/.+?\/links\/.+?\/node_modules(?=\/)/g; + +/** + * Replace pnpm virtual store paths with `` and strip + * the environment-dependent prefix so that local virtual store + * and global virtual store both normalize to `/pkg/...`. + */ +export function replacePnpmInnerPath(str: string): string { + const replaced = str + .replace(LOCAL_PNPM_POSIX, PNPM_INNER_TOKEN) + .replace(LOCAL_PNPM_WIN32, PNPM_INNER_TOKEN) + .replace(GLOBAL_PNPM_POSIX, PNPM_INNER_TOKEN); + + // Strip environment-dependent prefix before + // e.g. /node_modules/ + // Uses string splitting instead of regex to avoid ReDoS. + const parts = replaced.split(PNPM_INNER_TOKEN); + if (parts.length <= 1) return replaced; + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + let j = part.length - 1; + while (j >= 0 && !PNPM_INNER_DELIMITERS.test(part[j])) { + j--; + } + parts[i] = part.substring(0, j + 1); + } + + return parts.join(PNPM_INNER_TOKEN); +}