Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ jobs:
node-version: ${{matrix.node-version}}
cache: yarn
- run: yarn
- uses: actions/cache@v4
with:
path: |
test/pnpm-compat/.yarn/cache
test/pnpm-compat/.yarn/global
key: pnpm-compat-yarn-${{ runner.os }}-${{ hashFiles('test/pnpm-compat/yarn.lock') }}
- run: node test/smoketest.mjs
- run: yarn test
env:
Expand All @@ -57,6 +63,12 @@ jobs:
with:
cache: yarn
- run: yarn
- uses: actions/cache@v4
with:
path: |
test/pnpm-compat/.yarn/cache
test/pnpm-compat/.yarn/global
key: pnpm-compat-yarn-${{ runner.os }}-${{ hashFiles('test/pnpm-compat/yarn.lock') }}
- run: yarn prepack
- run: yarn test || yarn jest --no-silent --verbose --onlyFailures
- run: node test/smoketest.mjs
Expand Down
874 changes: 0 additions & 874 deletions .yarn/releases/yarn-3.6.3.cjs

This file was deleted.

940 changes: 940 additions & 0 deletions .yarn/releases/yarn-4.13.0.cjs

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
nodeLinker: pnpm
compressionLevel: mixed

enableGlobalCache: true

yarnPath: .yarn/releases/yarn-3.6.3.cjs
nodeLinker: node-modules

npmMinimalAgeGate: 2880

yarnPath: .yarn/releases/yarn-4.13.0.cjs
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"type": "git",
"url": "git+https://github.com/getappmap/appmap-node.git"
},
"packageManager": "yarn@3.6.3",
"packageManager": "yarn@4.13.0",
"type": "commonjs",
"engines": {
"node": ">=18"
Expand Down
5 changes: 3 additions & 2 deletions src/hooks/jest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ import {
startTestRecording,
} from "../recorder";
import genericTranform from "../transform";
import { matchesPackageFile } from "../util/matchesPackageFile";
import { isId } from "../util/isId";

export function shouldInstrument(url: URL): boolean {
return (
url.pathname.endsWith("jest-runtime/build/index.js") ||
url.pathname.endsWith("jest-circus/build/state.js")
matchesPackageFile(url.pathname, "jest-runtime", "build/index.js") ||
matchesPackageFile(url.pathname, "jest-circus", "build/state.js")
);
}

Expand Down
3 changes: 2 additions & 1 deletion src/hooks/mocha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ import {
getTestRecording,
startTestRecording,
} from "../recorder";
import { matchesPackageFile } from "../util/matchesPackageFile";

export function shouldInstrument(url: URL): boolean {
return url.pathname.endsWith("/mocha/lib/runner.js");
return matchesPackageFile(url.pathname, "mocha", "lib/runner.js");
}

export function transform(program: ESTree.Program): ESTree.Program {
Expand Down
32 changes: 13 additions & 19 deletions src/hooks/vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
startTestRecording,
} from "../recorder";
import genericTransform from "../transform";
import { matchesPackageFile } from "../util/matchesPackageFile";

function createInitChannel() {
return new worker.BroadcastChannel("appmap-node/vitest/initialized");
Expand Down Expand Up @@ -63,27 +64,18 @@ if (shouldListenForInit()) {
};
}

const vitestRunnerIndexJsFilePathEnding = "/@vitest/runner/dist/index.js";
// vitest v3 splits runTest into chunk-hooks.js; index.js is just re-exports
const vitestRunnerChunkHooksJsFilePathEnding = "/@vitest/runner/dist/chunk-hooks.js";
const viteNodeClientMjsFilePathEnding = "/vite-node/dist/client.mjs";
// vitest v4 uses vite's built-in module runner instead of vite-node
const viteModuleRunnerJsFilePathEnding = "/vite/dist/node/module-runner.js";
// vitest v4 uses VitestModuleEvaluator instead of ESModulesEvaluator
const vitestModuleEvaluatorJsFilePathEnding = "/vitest/dist/module-evaluator.js";

export function shouldInstrument(url: URL): boolean {
// 1. …/vite-node/dist/client.mjs ViteNodeRunner.runModule (vitest v0-v3)
// or …/vite/dist/node/module-runner.js ESModulesEvaluator.runInlinedModule (vitest v4)
// is the place to transform test and user files
// 2. @vitest/runner/dist/index.js (or chunk-hooks.js for v3) runTest
// is the place to intercept test before and afters
return (
url.pathname.endsWith(vitestRunnerIndexJsFilePathEnding) ||
url.pathname.endsWith(vitestRunnerChunkHooksJsFilePathEnding) ||
url.pathname.endsWith(viteNodeClientMjsFilePathEnding) ||
url.pathname.endsWith(viteModuleRunnerJsFilePathEnding) ||
url.pathname.endsWith(vitestModuleEvaluatorJsFilePathEnding)
matchesPackageFile(url.pathname, "@vitest/runner", "dist/index.js") ||
matchesPackageFile(url.pathname, "@vitest/runner", "dist/chunk-hooks.js") ||
matchesPackageFile(url.pathname, "vite-node", "dist/client.mjs") ||
matchesPackageFile(url.pathname, "vite", "dist/node/module-runner.js") ||
matchesPackageFile(url.pathname, "vitest", "dist/module-evaluator.js")
);
}

Expand Down Expand Up @@ -196,15 +188,16 @@ function patchRunInlinedModule(md: ESTree.MethodDefinition) {
export function transform(program: ESTree.Program): ESTree.Program {
const source = program.loc?.source;
if (
source?.endsWith(vitestRunnerIndexJsFilePathEnding) ||
source?.endsWith(vitestRunnerChunkHooksJsFilePathEnding)
source &&
(matchesPackageFile(source, "@vitest/runner", "dist/index.js") ||
matchesPackageFile(source, "@vitest/runner", "dist/chunk-hooks.js"))
)
walk(program, {
FunctionDeclaration(fd: ESTree.FunctionDeclaration) {
if (fd.id?.name === "runTest") patchRunTest(fd);
},
});
else if (source?.endsWith(viteNodeClientMjsFilePathEnding))
else if (source && matchesPackageFile(source, "vite-node", "dist/client.mjs"))
walk(program, {
ClassDeclaration(cd: ESTree.ClassDeclaration) {
if (cd.id?.name === "ViteNodeRunner") {
Expand All @@ -217,8 +210,9 @@ export function transform(program: ESTree.Program): ESTree.Program {
},
});
else if (
source?.endsWith(viteModuleRunnerJsFilePathEnding) ||
source?.endsWith(vitestModuleEvaluatorJsFilePathEnding)
source &&
(matchesPackageFile(source, "vite", "dist/node/module-runner.js") ||
matchesPackageFile(source, "vitest", "dist/module-evaluator.js"))
)
walk(program, {
MethodDefinition(md: ESTree.MethodDefinition) {
Expand Down
2 changes: 2 additions & 0 deletions src/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export function findHook(url: URL, sourceMap?: CustomSourceMapConsumer, hooks =
return hooks.find((h) => h.shouldInstrument(url, sourceMap));
}

export { matchesPackageFile } from "./util/matchesPackageFile";

export function shouldMatchOriginalSourcePaths(url: URL) {
// We're not interested in source maps of libraries. For instance, in a Next.js project,
// if we were to consult the source map for instrumentation of a file like
Expand Down
23 changes: 23 additions & 0 deletions src/util/matchesPackageFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Checks if a URL or path string matches a specific package file, handling multiple
* node_modules layouts:
* - Classic (npm, pnpm, yarn classic): .../node_modules/{pkg}/{file}
* - Yarn 3 pnpm linker: .../node_modules/.store/{pkg}-npm-{ver}-{hash}/{pkg}/{file}
* - Yarn 4 pnpm linker: .../node_modules/.store/{pkg}-npm-{ver}-{hash}/package/{file}
*/
export function matchesPackageFile(
urlOrPath: string,
packageName: string,
filePath: string,
): boolean {
if (urlOrPath.endsWith(`/${packageName}/${filePath}`)) return true;
// Yarn 4 pnpm linker uses 'package' as the subdirectory name inside .store entries
if (urlOrPath.endsWith(`/package/${filePath}`)) {
// Yarn 4 pnpm linker names store entries as:
// @scope/pkg → @scope-pkg-npm-ver-hash or @scope-pkg-virtual-hash
// pkg → pkg-npm-ver-hash or pkg-virtual-hash
const sanitized = packageName.replace("/", "-");
if (urlOrPath.includes(`/.store/${sanitized}-`)) return true;
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

matchesPackageFile() can return false positives for Yarn 4 pnpm store paths because it checks includes('/.store/${sanitized}-'). For example, packageName vite would also match a store entry like vite-node-... since it contains /.store/vite-. This could cause hooks to instrument/patch the wrong package file if the filePath suffix matches. Consider matching the store entry more strictly (e.g., require /.store/${sanitized}-(npm|virtual)- or otherwise enforce a delimiter after the package name).

Suggested change
if (urlOrPath.includes(`/.store/${sanitized}-`)) return true;
if (
urlOrPath.includes(`/.store/${sanitized}-npm-`) ||
urlOrPath.includes(`/.store/${sanitized}-virtual-`)
) {
return true;
}

Copilot uses AI. Check for mistakes.
}
return false;
}
2 changes: 1 addition & 1 deletion test/__snapshots__/next16.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ exports[`mapping a Next.js 16 appmap 1`] = `
"http_server_response": {
"headers": {
"Cache-Control": "no-store, must-revalidate",
"Content-Length": "2238",
"Content-Length": "2210",
"Content-Type": "text/html; charset=utf-8",
"Vary": "Accept-Encoding",
"X-Powered-By": "Next.js",
Expand Down
9 changes: 7 additions & 2 deletions test/next.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { IncomingMessage, request } from "node:http";
import { resolve } from "node:path";

import { getFreePort, integrationTest, readAppmaps, spawnAppmapNode } from "./helpers";

const nextBin = require.resolve("next/dist/bin/next", {
paths: [resolve(__dirname, "next")],
});

async function spawnNextJsApp(port: number) {
// On Windows, we give "node" argument explicitly because next is a js file with
// shebang (#!/usr/bin/env node) which does not work on Windows.
const app =
process.platform == "win32"
? spawnAppmapNode("node", "node_modules\\next\\dist\\bin\\next", "dev", "-p", String(port))
: spawnAppmapNode("node_modules/next/dist/bin/next", "dev", "-p", String(port));
? spawnAppmapNode("node", nextBin, "dev", "-p", String(port))
: spawnAppmapNode(nextBin, "dev", "-p", String(port));

await new Promise<void>((r) => {
const onData = (chunk: Buffer) => {
Expand Down
9 changes: 7 additions & 2 deletions test/next16.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { IncomingMessage, request } from "node:http";
import { resolve } from "node:path";

import { getFreePort, integrationTest, readAppmaps, spawnAppmapNode } from "./helpers";

const nextBin = require.resolve("next/dist/bin/next", {
paths: [resolve(__dirname, "next16")],
});

async function spawnNextJsApp(port: number) {
// On Windows, we give "node" argument explicitly because next is a js file with
// shebang (#!/usr/bin/env node) which does not work on Windows.
// Turbopack (the default bundler in Next.js 16) respects webpack loaders configured
// via next.config turbopack.rules, which we inject in src/hooks/next.ts.
const app =
process.platform == "win32"
? spawnAppmapNode("node", "node_modules\\next\\dist\\bin\\next", "dev", "-p", String(port))
: spawnAppmapNode("node_modules/next/dist/bin/next", "dev", "-p", String(port));
? spawnAppmapNode("node", nextBin, "dev", "-p", String(port))
: spawnAppmapNode(nextBin, "dev", "-p", String(port));

await new Promise<void>((r) => {
const onData = (chunk: Buffer) => {
Expand Down
22 changes: 22 additions & 0 deletions test/pnpm-compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";

import { integrationTest, readAppmaps, runAppmapNode } from "./helpers";

// This test runs vitest in a separate project installed with yarn pnpm linker,
// verifying that matchesPackageFile() correctly identifies hook targets in the
// .store/pkg-npm-version-hash/package/ path format used by yarn 4 pnpm linker.

integrationTest("pnpm linker: vitest hooks instrument code in .store paths", () => {
const dir = resolve(__dirname, "pnpm-compat");
if (!existsSync(resolve(dir, "node_modules")))
spawnSync("yarn", ["install"], {
cwd: dir,
stdio: "inherit",
shell: process.platform === "win32",
});
Comment on lines +11 to +18
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test only runs yarn install when node_modules/ doesn't exist. That can lead to stale dependencies locally (e.g., if test/pnpm-compat/yarn.lock changes but node_modules/ is already present), making the test non-deterministic. Consider always running yarn install --immutable (or another lockfile-enforcing check) in this test setup; CI performance can still be preserved via the workflow cache for test/pnpm-compat/.yarn/*.

Copilot uses AI. Check for mistakes.

expect(runAppmapNode("yarn", "vitest", "run").status).toBe(0);
expect(readAppmaps()).not.toEqual({});
});
1 change: 1 addition & 0 deletions test/pnpm-compat/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.yarn/
9 changes: 9 additions & 0 deletions test/pnpm-compat/.yarnrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
nodeLinker: pnpm

enableGlobalCache: false

cacheFolder: ./.yarn/cache

globalFolder: ./.yarn/global

yarnPath: ../../.yarn/releases/yarn-4.13.0.cjs
4 changes: 4 additions & 0 deletions test/pnpm-compat/appmap.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
name: pnpm-compat-appmap-node-test
packages:
- path: .
Comment on lines +2 to +3
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appmap.yml explicitly sets packages: [{ path: '.' }] but doesn't exclude node_modules/.yarn. In this repo the default package config excludes those directories (see src/config.ts default packages), and most test apps rely on that to avoid instrumenting dependencies. Consider either removing packages: entirely (to fall back to defaults) or adding an exclude list so the pnpm-compat test doesn't instrument the entire dependency tree (which can significantly slow tests and bloat generated AppMaps).

Suggested change
packages:
- path: .

Copilot uses AI. Check for mistakes.
language: javascript
7 changes: 7 additions & 0 deletions test/pnpm-compat/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "pnpm-compat-appmap-node-test",
"private": true,
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test project package.json omits packageManager. Other test subprojects pin Yarn via packageManager (e.g. test/mocha/package.json:3), which helps Corepack/CI reliably run the intended Yarn version when executing commands from that subdirectory. Consider adding "packageManager": "yarn@4.13.0" here to ensure yarn install/yarn vitest in test/pnpm-compat/ consistently uses Yarn 4.

Suggested change
"private": true,
"private": true,
"packageManager": "yarn@4.13.0",

Copilot uses AI. Check for mistakes.
"dependencies": {
"vitest": "^0.34.6"
}
}
9 changes: 9 additions & 0 deletions test/pnpm-compat/sum.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { expect, test } from "vitest";

function sum(a, b) {
return a + b;
}

test("adds 1 + 2 to equal 3", () => {
expect(sum(1, 2)).toBe(3);
});
2 changes: 2 additions & 0 deletions test/pnpm-compat/vitest.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { defineConfig } from "vitest/config";
export default defineConfig({});
Loading
Loading