Skip to content

Commit

Permalink
fix: Windows support (#89)
Browse files Browse the repository at this point in the history
* ci: Add windows test build step
* fix: Windows support
* Don't replace : on unix

---------

Co-authored-by: Rafał Rzepecki <divided.mind@gmail.com>
  • Loading branch information
zermelo-wisen and dividedmind authored Jan 24, 2024
1 parent 9b44adf commit 7d88965
Show file tree
Hide file tree
Showing 27 changed files with 188 additions and 54 deletions.
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ jobs:
env:
POSTGRES_URL: postgres://postgres:postgres@localhost:5432

windows-test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: yarn
- run: yarn prepack
- run: yarn test
- run: node test/smoketest.mjs

release:
runs-on: ubuntu-latest
permissions:
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
20 changes: 16 additions & 4 deletions src/PackageMatcher.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,37 @@
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import fwdSlashPath from "./util/fwdSlashPath";

export default class PackageMatcher extends Array<Package> {
constructor(
private root: string,
packages: Package[],
) {
// Normalize path separators
packages.forEach((p) => {
p.path = fwdSlashPath(p.path);
if (p.exclude)
for (let i = 0; i < p.exclude.length; i++) p.exclude[i] = fwdSlashPath(p.exclude[i]);
});

super(...packages);
this.resolved = new Map(packages.map(({ path }) => [path, resolve(root, path)]));
this.resolved = new Map(packages.map(({ path }) => [path, fwdSlashPath(resolve(root, path))]));
}

private resolved: Map<string, string>;

private resolve(path: string) {
return this.resolved.get(path) ?? resolve(this.root, path);
return this.resolved.get(path) ?? fwdSlashPath(resolve(this.root, path));
}

match(path: string): Package | undefined {
if (path.startsWith("file:")) path = fileURLToPath(path);
const pkg = this.find((pkg) => path.startsWith(this.resolve(pkg.path)));
return pkg?.exclude?.find((ex) => path.includes(ex)) ? undefined : pkg;

// Make sure passed path is forward slashed
const fixedPath = fwdSlashPath(path);

const pkg = this.find((pkg) => fixedPath.startsWith(this.resolve(pkg.path)));
return pkg?.exclude?.find((ex) => fixedPath.includes(ex)) ? undefined : pkg;
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/Recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@ function makeAppMapFilename(name: string): string {
return name + ".appmap.json";
}

const charsToQuote = process.platform == "win32" ? /[/\\:<>*"|?]/g : /[/\\]/g;
function quotePathSegment(value: string): string {
// note replacing spaces isn't strictly necessary improves UX
return value.replaceAll(/[/\\]/g, "-").replaceAll(" ", "_");
return value.replaceAll(charsToQuote, "-").replaceAll(" ", "_");
}
13 changes: 8 additions & 5 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import YAML from "yaml";

import PackageMatcher from "../PackageMatcher";
import { Config } from "../config";
import { fixAbsPath } from "../hooks/__tests__/fixAbsPath";

tmp.setGracefulCleanup();

Expand Down Expand Up @@ -98,10 +99,12 @@ describe(Config, () => {
describe(PackageMatcher, () => {
it("matches packages", () => {
const pkg = { path: ".", exclude: ["node_modules", ".yarn"] };
const matcher = new PackageMatcher("/test/app", [pkg]);
expect(matcher.match("/test/app/lib/foo.js")).toEqual(pkg);
expect(matcher.match("/other/app/lib/foo.js")).toBeUndefined();
expect(matcher.match("/test/app/node_modules/lib/foo.js")).toBeUndefined();
expect(matcher.match("/test/app/.yarn/lib/foo.js")).toBeUndefined();
const matcher = new PackageMatcher(fixAbsPath("/test/app"), [pkg]);
expect(matcher.match(fixAbsPath("/test/app/lib/foo.js"))).toEqual(pkg);
if (process.platform == "win32")
expect(matcher.match(fixAbsPath("\\test\\app\\lib\\foo.js"))).toEqual(pkg);
expect(matcher.match(fixAbsPath("/other/app/lib/foo.js"))).toBeUndefined();
expect(matcher.match(fixAbsPath("/test/app/node_modules/lib/foo.js"))).toBeUndefined();
expect(matcher.match(fixAbsPath("/test/app/.yarn/lib/foo.js"))).toBeUndefined();
});
});
15 changes: 13 additions & 2 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ChildProcess, spawn } from "node:child_process";
import { accessSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { kill, pid } from "node:process";
import { pathToFileURL } from "node:url";

import stripJsonComments from "strip-json-comments";
import YAML from "yaml";
Expand All @@ -14,7 +15,10 @@ import forwardSignals from "./util/forwardSignals";
import { readPkgUp } from "./util/readPkgUp";

const registerPath = resolve(__dirname, "../dist/register.js");
const loaderPath = resolve(__dirname, "../dist/loader.js");
// We need to convert c: to file:// in Windows because:
// "Error: Only URLs with a scheme in: file, data, and node are supported by the default ESM loader.
// On Windows, absolute paths must be valid file:// URLs. Received protocol 'c:'"
const loaderPath = pathToFileURL(resolve(__dirname, "../dist/loader.js")).href;

export function main() {
const [cmd, ...args] = process.argv.slice(2);
Expand Down Expand Up @@ -46,7 +50,14 @@ export function main() {
}
} else {
// it's a command, spawn it
child = spawn(cmd, args, { stdio: "inherit" });

// We need to give shell: true in Windows because we get "Error: spawn yarn ENOENT"
// for example when the cmd is yarn. Looks like it needs the full path of the
// executable otherwise.
// Related articles:
// - https://stackoverflow.com/questions/37459717/error-spawn-enoent-on-windows
// - https://github.com/nodejs/node/issues/7367#issuecomment-238594729
child = spawn(cmd, args, { stdio: "inherit", shell: process.platform == "win32" });
}

forwardSignals(child);
Expand Down
4 changes: 2 additions & 2 deletions src/classMap.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import assert from "node:assert";
import { sep } from "node:path";

import type AppMap from "./AppMap";
import type { FunctionInfo, SourceLocation } from "./registry";
Expand All @@ -13,7 +12,8 @@ export function makeClassMap(funs: Iterable<FunctionInfo>): AppMap.ClassMap {
// sorting isn't strictly necessary, but it provides for a stable output
for (const fun of sortFunctions(funs)) {
if (!fun.location) continue;
const pkgs = fun.location.path.replace(/\..+$/, "").split(sep).reverse();
// fun.location can contain "/" as separator even in Windows
const pkgs = fun.location.path.replace(/\..+$/, "").split(/[/\\]/).reverse();
if (pkgs.length > 1) pkgs.shift(); // remove the file name (e.g. "foo.js")

let [tree, classes]: FNode = [root, {}];
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export class Config {
const config = readConfigFile(this.configPath);
this.default = !config;

this.relativeAppmapDir = config?.appmap_dir ?? join("tmp", "appmap");
this.relativeAppmapDir = config?.appmap_dir ?? "tmp/appmap";

this.appName = config?.name ?? targetPackage()?.name ?? basename(root);

Expand Down
6 changes: 6 additions & 0 deletions src/hooks/__tests__/fixAbsPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Add drive parts for absolute paths in Windows.
export function fixAbsPath(pathOrFileUrl: string) {
if (process.platform != "win32") return pathOrFileUrl;
if (pathOrFileUrl.startsWith("/") || pathOrFileUrl.startsWith("\\")) return "F:" + pathOrFileUrl;
return pathOrFileUrl.replace("file:///", "file:///F:/");
}
16 changes: 9 additions & 7 deletions src/hooks/__tests__/instrument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,25 @@ import { full as walk } from "acorn-walk";
import { ESTree, parse } from "meriyah";

import config from "../../config";
import { fixAbsPath } from "./fixAbsPath";
import * as instrument from "../instrument";
import PackageMatcher from "../../PackageMatcher";
import * as registry from "../../registry";
import * as instrument from "../instrument";

describe(instrument.shouldInstrument, () => {
jest.replaceProperty(config, "root", "/test");
jest.replaceProperty(config, "root", fixAbsPath("/test"));
jest.replaceProperty(
config,
"packages",
new PackageMatcher("/test", [{ path: ".", exclude: ["node_modules"] }]),
new PackageMatcher(fixAbsPath("/test"), [{ path: ".", exclude: ["node_modules"] }]),
);

test.each([
["node:test", false],
["file:///test/test.json", false],
["file:///var/test.js", false],
["file:///test/test.js", true],
["file:///test/node_modules/test.js", false],
[fixAbsPath("file:///test/test.json"), false],
[fixAbsPath("file:///var/test.js"), false],
[fixAbsPath("file:///test/test.js"), true],
[fixAbsPath("file:///test/node_modules/test.js"), false],
])("%s", (url, expected) => expect(instrument.shouldInstrument(new URL(url))).toBe(expected));
});

Expand Down
8 changes: 6 additions & 2 deletions src/hooks/__tests__/jest.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { parse } from "meriyah";

import { fixAbsPath } from "./fixAbsPath";
import * as jestHook from "../jest";
import transform from "../../transform";

Expand Down Expand Up @@ -53,9 +55,11 @@ describe(jestHook.patchRuntime, () => {
describe(jestHook.transformJest, () => {
it("pushes jest transformed code through appmap hooks", () => {
jest.mocked(transform).mockReturnValue("transformed test code");
const result = jestHook.transformJest.call(undefined, () => "test code", ["/test/test.js"]);
const result = jestHook.transformJest.call(undefined, () => "test code", [
fixAbsPath("/test/test.js"),
]);
expect(result).toBe("transformed test code");
expect(transform).toBeCalledWith("test code", new URL("file:///test/test.js"));
expect(transform).toBeCalledWith("test code", new URL(fixAbsPath("file:///test/test.js")));
});
});

Expand Down
4 changes: 2 additions & 2 deletions src/hooks/vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ function patchRunTest(fd: ESTree.FunctionDeclaration) {
// Statement: return await import(".../vitest.js").wrapRunTest(function runTest(...) {...}, arguments);
ret(
call_(
member(awaitImport(__filename), identifier(wrapRunTest.name)),
member(awaitImport(pathToFileURL(__filename).href), identifier(wrapRunTest.name)),
{ ...fd, type: "FunctionExpression" },
args_,
),
Expand All @@ -137,7 +137,7 @@ function patchRunModule(md: ESTree.MethodDefinition) {
const transformCodeStatement = assignment(
identifier("transformed"),
call_(
member(awaitImport(__filename), identifier(transformCode.name)),
member(awaitImport(pathToFileURL(__filename).href), identifier(transformCode.name)),
identifier("transformed"),
memberId("context", "__filename"),
),
Expand Down
3 changes: 3 additions & 0 deletions src/util/__tests__/commonPathPrefix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ describe(commonPathPrefix, () => {
[["/foo/bar", "/foo/barbara"], "/foo/"],
[["/foo/bar", "/other/dir"], "/"],
])("%j => %s", (paths, expected) => expect(commonPathPrefix(paths)).toBe(expected));

if (process.platform == "win32")
expect(commonPathPrefix(["c:\\foo\\bar", "C:\\Foo\\Baz"])).toBe("c:\\foo\\");
});
11 changes: 9 additions & 2 deletions src/util/commonPathPrefix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@ export default function commonPathPrefix(paths: string[]): string {
const [first, ...others] = paths;
if (!first) return "";

const isEqual = (a: string, b: string) => {
return process.platform == "win32"
? a.localeCompare(b, "en", { sensitivity: "base" }) == 0
: a == b;
};

let prefixLen = 0;
for (let i = 0; i < first.length; i++)
if (!others.every((path) => path[i] === first[i])) break;
else if (first[i] === sep) prefixLen = i;
if (!others.every((path) => isEqual(path[i], first[i]))) break;
// We occasionally convert back slash to forward slash even in Windows
else if (first[i] === sep || first[i] === "/") prefixLen = i;

return first.slice(0, prefixLen + 1);
}
4 changes: 4 additions & 0 deletions src/util/fwdSlashPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default function fwdSlashPath(path: string): string {
if (process.platform != "win32") return path;
return path?.replaceAll("\\", "/");
}
4 changes: 2 additions & 2 deletions test/__snapshots__/next.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ exports[`mapping a Next.js appmap 1`] = `
},
"version": "1.12",
},
"./tmp/appmap/requests/<timestamp 1> -about.appmap.json": {
"./tmp/appmap/requests/<timestamp 1>_-about.appmap.json": {
"classMap": [
{
"children": [
Expand All @@ -129,7 +129,7 @@ exports[`mapping a Next.js appmap 1`] = `
"type": "class",
},
],
"name": "about",
"name": "pages",
"type": "package",
},
],
Expand Down
17 changes: 9 additions & 8 deletions test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import caller from "caller";
import { globSync } from "fast-glob";

import type AppMap from "../src/AppMap";
import fwdSlashPath from "../src/util/fwdSlashPath";

const binPath = resolve(__dirname, "../bin/appmap-node.js");

Expand All @@ -29,13 +30,13 @@ export function spawnAppmapNode(...args: string[]): ChildProcessWithoutNullStrea
return result;
}

let target = cwd();
let target = fwdSlashPath(cwd());

export function testDir(path: string) {
target = resolve(path);
target = fwdSlashPath(resolve(path));
}

beforeEach(() => rmSync(resolveTarget("tmp"), { recursive: true, force: true }));
beforeEach(() => rmSync(resolveTarget("tmp"), { recursive: true, force: true, maxRetries: 3 }));

export function resolveTarget(...path: string[]): string {
return resolve(target, ...path);
Expand All @@ -55,7 +56,7 @@ type AppMap = object & Record<"events", unknown>;

export function readAppmap(path?: string): AppMap.AppMap {
if (!path) {
const files = globSync(resolve(target, "tmp/**/*.appmap.json"));
const files = globSync(fwdSlashPath(resolve(target, "tmp/**/*.appmap.json")));
expect(files.length).toBe(1);
[path] = files;
}
Expand All @@ -80,7 +81,7 @@ export function fixAppmap(map: unknown): AppMap.AppMap {
}

export function readAppmaps(): Record<string, AppMap.AppMap> {
const files = globSync(resolve(target, "tmp/**/*.appmap.json"));
const files = globSync(fwdSlashPath(resolve(target, "tmp/**/*.appmap.json")));
const maps = files.map<[string, AppMap.AppMap]>((path) => [fixPath(path), readAppmap(path)]);
return Object.fromEntries(maps);
}
Expand Down Expand Up @@ -128,13 +129,13 @@ beforeEach(() => (timestampId = 0));

function fixTimeStamps(str: string): string {
return str.replaceAll(
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/g,
(ts) => (timestamps[ts] ||= `<timestamp ${timestampId++}>`),
/\d{4}-\d{2}-\d{2}T\d{2}[:-]\d{2}[:-]\d{2}\.\d{3}Z/g,
(ts) => (timestamps[ts.replaceAll(":", "-")] ||= `<timestamp ${timestampId++}>`),
);
}

function fixPath(path: string): string {
return fixTimeStamps(path.replace(target, "."));
return fixTimeStamps(fwdSlashPath(path).replace(target, "."));
}

function fixClassMap(classMap: unknown[]) {
Expand Down
18 changes: 13 additions & 5 deletions test/httpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ integrationTest("mapping Express.js requests", async () => {
expect.assertions(1);
const server = await spawnServer("express.js");
await makeRequests();
// Wait for the last request to finish
await awaitStdoutOnData(server, "api-bar.appmap.json");
await killServer(server);
expect(readAppmaps()).toMatchSnapshot();
});
Expand All @@ -15,6 +17,8 @@ integrationTest("mapping node:http requests", async () => {
expect.assertions(1);
const server = await spawnServer("vanilla.js");
await makeRequests();
// Wait for the last request to finish
await awaitStdoutOnData(server, "api-bar.appmap.json");
await killServer(server);
expect(readAppmaps()).toMatchSnapshot();
});
Expand All @@ -39,6 +43,14 @@ integrationTest("mapping node:http requests with remote recording", async () =>
await killServer(server);
});

async function awaitStdoutOnData(server: ChildProcessWithoutNullStreams, searchString: string) {
await new Promise<void>((r) =>
server.stdout.on("data", (chunk: Buffer) => {
if (chunk.toString().includes(searchString)) r();
}),
);
}

async function makeRequests() {
await makeRequest("");
await makeRequest("/nonexistent");
Expand All @@ -58,11 +70,7 @@ async function makeRequests() {

async function spawnServer(script: string) {
const server = spawnAppmapNode(script);
await new Promise<void>((r) =>
server.stdout.on("data", (chunk: Buffer) => {
if (chunk.toString().includes("listening")) r();
}),
);
await awaitStdoutOnData(server, "listening");
return server;
}

Expand Down
Loading

0 comments on commit 7d88965

Please sign in to comment.