Skip to content

Commit 6d757ef

Browse files
authored
Make it work on Windows (#748)
1 parent c6ab34c commit 6d757ef

13 files changed

+98
-39
lines changed

.changeset/tiny-years-tie.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'pleasantest': minor
3+
---
4+
5+
Reimplement windows support
6+
7+
Long ago, Pleasantest worked on Windows, but without regular testing it gradually diverged. This release adds proper Windows support back and adds automated testing for it.

.github/workflows/ci.yml

+3-1
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ concurrency:
1212

1313
jobs:
1414
test:
15-
runs-on: ubuntu-latest
15+
runs-on: ${{ matrix.platform }}
1616
strategy:
17+
fail-fast: false
1718
matrix:
1819
node-version: [18.x, 20.x, 22.x]
20+
platform: [ubuntu-latest, windows-latest]
1921
steps:
2022
- uses: actions/checkout@v4
2123
- name: Use Node.js ${{ matrix.node-version }}

package.json

+9-7
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,15 @@
117117
},
118118
"main": "./dist/cjs/index.cjs",
119119
"exports": {
120-
"require": {
121-
".": "./dist/cjs/index.cjs",
122-
"types": "./dist/index.d.cts"
123-
},
124-
"import": {
125-
".": "./dist/esm/index.mjs",
126-
"types": "./dist/index.d.mts"
120+
".": {
121+
"require": {
122+
"default": "./dist/cjs/index.cjs",
123+
"types": "./dist/index.d.cts"
124+
},
125+
"import": {
126+
"default": "./dist/esm/index.mjs",
127+
"types": "./dist/index.d.mts"
128+
}
127129
}
128130
},
129131
"types": "./dist/index.d.ts",

src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export const withBrowser: WithBrowser = (...args: any[]) => {
124124
// ignore if it is the current file
125125
if (stackItem === thisFile) return false;
126126
// ignore if it is an internal-to-node thing
127-
if (!stackItem.startsWith('/')) return false;
127+
if (stackItem.startsWith('node:')) return false;
128128
// Find the first item that is not the current file
129129
return true;
130130
});

src/jest-dom/rollup.config.js

+10-7
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
1-
import * as childProcess from 'node:child_process';
2-
import { promisify } from 'node:util';
1+
import { fork } from 'node:child_process';
32

43
import babel from '@rollup/plugin-babel';
54
import nodeResolve from '@rollup/plugin-node-resolve';
65
import terser from '@rollup/plugin-terser';
76

87
import { rollupPluginDomAccessibilityApi } from '../rollup-plugin-dom-accessibility-api.js';
98

10-
const exec = promisify(childProcess.exec);
11-
129
const extensions = ['.js', '.jsx', '.es6', '.es', '.mjs', '.ts', '.tsx'];
1310

14-
const { stdout, stderr } = await exec('./node_modules/.bin/patch-package');
15-
process.stdout.write(stdout);
16-
process.stderr.write(stderr);
11+
await new Promise((resolve, reject) => {
12+
const child = fork('./node_modules/patch-package/index.js', [], {
13+
stdio: 'inherit',
14+
});
15+
child.on('exit', (code) => {
16+
if (code === 0) resolve();
17+
else reject(new Error(`patch-package exited with code ${code}`));
18+
});
19+
});
1720

1821
const stubs = {
1922
chalk: `

src/module-server/bundle-npm-module.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,10 @@ export const bundleNpmModule = async (
7070
},
7171
load(id) {
7272
if (id === virtualEntry) {
73-
const code = `export * from '${mod}'
74-
export {${namedExports.join(', ')}} from '${mod}'
75-
export { default } from '${mod}'`;
76-
return code;
73+
const modNameString = JSON.stringify(mod);
74+
return `export * from ${modNameString}
75+
export {${namedExports.join(', ')}} from ${modNameString}
76+
export { default } from ${modNameString}`;
7777
}
7878
},
7979
} as Plugin),

src/module-server/extensions-and-detection.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
import { isAbsolute } from 'node:path';
12
export const npmPrefix = '@npm/';
23

34
export const isRelativeOrAbsoluteImport = (id: string) =>
45
id === '.' ||
56
id === '..' ||
67
id.startsWith('./') ||
78
id.startsWith('../') ||
8-
id.startsWith('/');
9+
isAbsolute(id);
910

1011
export const isBareImport = (id: string) =>
1112
!(

src/module-server/middleware/js.ts

+16-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { promises as fs } from 'node:fs';
2-
import { dirname, posix, relative, resolve, sep } from 'node:path';
2+
import { dirname, isAbsolute, posix, relative, resolve, sep } from 'node:path';
33

44
import type { DecodedSourceMap, RawSourceMap } from '@ampproject/remapping';
55
import MagicString from 'magic-string';
@@ -102,7 +102,8 @@ export const jsMiddleware = async ({
102102
import.meta.pleasantestArgs = [...window._pleasantestArgs]
103103
}`;
104104
const fileSrc = await fs.readFile(file, 'utf8');
105-
const inlineStartIdx = fileSrc.indexOf(code);
105+
const EOL = detectLineEndings(fileSrc);
106+
const inlineStartIdx = fileSrc.indexOf(code.replaceAll('\n', EOL));
106107
code = injectedArgsCode + code;
107108
if (inlineStartIdx !== -1) {
108109
const str = new MagicString(fileSrc);
@@ -180,16 +181,16 @@ export const jsMiddleware = async ({
180181
if (resolved) {
181182
spec = typeof resolved === 'object' ? resolved.id : resolved;
182183
if (spec.startsWith('@npm/')) return addBuildId(`/${spec}`);
183-
if (/^(\/|\\|[a-z]:\\)/i.test(spec)) {
184+
if (isAbsolute(path)) {
184185
// Change FS-absolute paths to relative
185-
spec = relative(dirname(file), spec).split(sep).join(posix.sep);
186+
spec = relative(dirname(file), spec).split(sep).join('/');
186187
if (!/^\.?\.?\//.test(spec)) spec = `./${spec}`;
187188
}
188189

189190
if (typeof resolved === 'object' && resolved.external) {
190191
if (/^(data|https?):/.test(spec)) return spec;
191192

192-
spec = relative(root, spec).split(sep).join(posix.sep);
193+
spec = relative(root, spec).split(sep).join('/');
193194
if (!/^(\/|[\w-]+:)/.test(spec)) spec = `/${spec}`;
194195
return addBuildId(spec);
195196
}
@@ -233,3 +234,13 @@ export const jsMiddleware = async ({
233234
}
234235
};
235236
};
237+
238+
const detectLineEndings = (fileSrc: string) => {
239+
// Find the first line end (\n) and check if the character before is \r
240+
// This tells us whether the file uses \r\n or just \n for line endings.
241+
// Using node:os.EOL is not sufficient because git on windows
242+
// Can be configured to check out files with either kind of line ending.
243+
const firstLineEndPos = fileSrc.indexOf('\n');
244+
if (fileSrc[firstLineEndPos - 1] === '\r') return '\r\n';
245+
return '\n';
246+
};

src/module-server/node-resolve.test.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,22 @@ const createFs = async (input: string) => {
4141
);
4242

4343
/** Replaces all instances of randomized tmp dir with "." */
44-
const unrandomizePath = (text: string) => text.split(dir).join('.');
44+
const unrandomizePath = (text: string) => text.replaceAll(dir, '.');
4545

4646
const resolve = async (id: string, { from }: { from?: string } = {}) => {
4747
const result = await nodeResolve(
4848
id,
4949
join(dir, from || 'index.js'),
5050
dir,
5151
).catch((error) => {
52-
throw changeErrorMessage(error, (error) => unrandomizePath(error));
52+
throw changeErrorMessage(error, (error) =>
53+
unrandomizePath(error).replaceAll(sep, '/'),
54+
);
5355
});
5456
if (result)
55-
return unrandomizePath(typeof result === 'string' ? result : result.path);
57+
return unrandomizePath(
58+
typeof result === 'string' ? result : result.path,
59+
).replaceAll(sep, '/');
5660
};
5761

5862
return { resolve };

src/module-server/node-resolve.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { promises as fs } from 'node:fs';
2-
import { dirname, join, resolve as pResolve, posix, relative } from 'node:path';
2+
import { dirname, join, resolve as pResolve, relative, sep } from 'node:path';
33

44
import { resolve, legacy as resolveLegacy } from 'resolve.exports';
55

@@ -129,7 +129,8 @@ export const resolveFromNodeModules = async (
129129
const cacheKey = resolveCacheKey(id, importer, root);
130130
const cached = resolveCache.get(cacheKey);
131131
if (cached) return cached;
132-
const pathChunks = id.split(posix.sep);
132+
// Split the path up into chunks based on either kind of slash
133+
const pathChunks = id.split(/[/\\]/g);
133134
const isNpmNamespace = id[0] === '@';
134135
// If it is an npm namespace, then get the first two folders, otherwise just one
135136
const packageName = pathChunks.slice(0, isNpmNamespace ? 2 : 1);
@@ -161,7 +162,10 @@ export const resolveFromNodeModules = async (
161162
}
162163

163164
const pkgJson = await readPkgJson(pkgDir);
164-
const main = readMainFields(pkgJson, subPath, true);
165+
// Main/exports fields in package.json are defined with forward slashes.
166+
// On windows, subPath will have \ instead of /, but we need to change it
167+
// to match what will be listed in the package.json.
168+
const main = readMainFields(pkgJson, subPath.replaceAll(sep, '/'), true);
165169
let result;
166170
if (main) result = join(pkgDir, main);
167171
else if (!('exports' in pkgJson)) {

src/module-server/rollup-plugin-container.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
- Updated to use import { parseAst } from 'rollup/parseAst' instead of acorn (rollup v4 change)
6666
*/
6767

68+
import { promises as fs } from 'node:fs';
6869
import { dirname, resolve } from 'node:path';
6970

7071
import type { DecodedSourceMap, RawSourceMap } from '@ampproject/remapping';
@@ -279,7 +280,7 @@ export const createPluginContainer = (plugins: Plugin[]) => {
279280
}
280281
} catch (error) {
281282
if (error instanceof ErrorWithLocation) {
282-
if (!error.filename) error.filename = id;
283+
if (!error.filename) error.filename = await fs.realpath(id);
283284
// If the error has a location,
284285
// apply the source maps to get the original location
285286
const line = error.line;
@@ -301,7 +302,11 @@ export const createPluginContainer = (plugins: Plugin[]) => {
301302
? undefined
302303
: sourceLocation.column;
303304
}
304-
error.filename = sourceLocation.source || id;
305+
// Source map filenames get URI encoded
306+
error.filename = sourceLocation.source
307+
? // Fix path slashes (windows), drive capitalization (windows)
308+
await fs.realpath(decodeURIComponent(sourceLocation.source))
309+
: id;
305310
}
306311
}
307312

src/module-server/transform-imports.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
- Parsing errors are thrown with code frame
3131
*/
3232

33+
import { promises as fs } from 'node:fs';
3334
import { extname } from 'node:path';
3435

3536
import type { DecodedSourceMap, RawSourceMap } from '@ampproject/remapping';
@@ -77,7 +78,7 @@ export const transformImports = async (
7778
message: `Error parsing module with es-module-lexer.${suggestion}`,
7879
line,
7980
column,
80-
filename: id,
81+
filename: await fs.realpath(id),
8182
});
8283

8384
if (map) {
@@ -90,7 +91,11 @@ export const transformImports = async (
9091
modifiedError.column =
9192
sourceLocation.column === null ? undefined : sourceLocation.column;
9293
}
93-
modifiedError.filename = sourceLocation.source || id;
94+
// Source map filenames get URI encoded
95+
modifiedError.filename = sourceLocation.source
96+
? // Fix path slashes (windows), drive capitalization (windows)
97+
await fs.realpath(decodeURIComponent(sourceLocation.source))
98+
: id;
9499
}
95100
throw modifiedError;
96101
}

tests/test-utils.ts

+18-3
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ export const printErrorFrames = async (error?: Error) => {
2323
return stackFrame.raw;
2424
}
2525

26-
const relativePath = path.relative(process.cwd(), stackFrame.fileName);
26+
const relativePath = path
27+
.relative(process.cwd(), stackFrame.fileName)
28+
.replaceAll(path.sep, '/');
2729
if (relativePath.startsWith('dist/')) return relativePath;
2830
let file;
2931
try {
@@ -48,12 +50,25 @@ const stripAnsi = (input: string) => input.replace(ansiRegex(), '');
4850

4951
const removeLineNumbers = (input: string) => {
5052
const lineRegex = /^\s*?\s*(\d)*\s+/gm;
51-
const fileRegex = new RegExp(`${process.cwd()}([a-zA-Z/._-]*)[\\d:]*`, 'g');
53+
// Creates a regex for the current directory.
54+
// Backslashes need to be escaped for use in the regex.
55+
// The [\\d:] part (double escaped because it is in a string) allows digits or colons
56+
// (to match the line/column number)
57+
const fileRegex = new RegExp(
58+
`${process.cwd().replaceAll('\\', '\\\\')}([a-zA-Z/\\\\._-]*)[\\d:]*`,
59+
'g',
60+
);
5261
return (
5362
input
5463
.replace(lineRegex, (_match, lineNum) => (lineNum ? ' ### │' : ' │'))
5564
// Take out the file paths so the tests will pass on more than 1 person's machine
56-
.replace(fileRegex, '<root>$1:###:###')
65+
// Take out the line numbers so that code changes shifting the line numbers
66+
// don't break the tests
67+
.replace(
68+
fileRegex,
69+
(_match, relativePath) =>
70+
`<root>${relativePath.replaceAll(path.sep, '/')}:###:###`,
71+
)
5772
);
5873
};
5974

0 commit comments

Comments
 (0)